From cb3a7199d161f7cb6af81e216e20be6cc2b6b303 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:06:39 -0700 Subject: [PATCH 1/7] fix(v2): route the Connectors and Tools copy through i18n (TASK-164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both surfaces rendered English under zh-CN: 180 keys were missing from the i18n namespaces and neither component was in the eslint manifest, so translationKeys.test.ts never saw the literals they used. - en.json + zh-CN.json: connectors.* and tools.* keys, identical key sets - V2ConnectorsPage/V2ConnectorTools: hardcoded copy -> t(), including three line builders that interpolated around a literal (linkedLine, waitingToConnect, mayUse) so a reordering language can move the clause - joinList() for the list separator (、 in zh) rather than ', ' at each site - .eslintrc.js manifest: add both files so i18next/no-literal-string covers them --- frontend/.eslintrc.js | 5 + frontend/src/i18n/locales/en.json | 182 +++++++++++++++++- frontend/src/i18n/locales/zh-CN.json | 182 +++++++++++++++++- .../src/v2/components/V2ConnectorTools.tsx | 23 ++- .../src/v2/components/V2ConnectorsPage.tsx | 22 ++- 5 files changed, 395 insertions(+), 19 deletions(-) diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 97ef11692..0c431e880 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -65,6 +65,11 @@ module.exports = { 'src/v2/components/V2Inspector.tsx', 'src/v2/components/V2PodsSidebar.tsx', 'src/v2/components/V2YourTeamPage.tsx', + // Connectors + Tools (TASK-164) — the channels list and the grants list + // rendered English defaults under zh-CN: neither file was in this manifest, + // so translationKeys.test.ts never saw the keys they were using. + 'src/v2/components/V2ConnectorsPage.tsx', + 'src/v2/components/V2ConnectorTools.tsx', ], rules: { 'i18next/no-literal-string': ['error', { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a66baa26e..1b5f5c45e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1883,7 +1883,96 @@ "since": "since {{age}}", "paused": "paused {{age}}", "slackAnswered": "Slack answered {{age}}" - } + }, + "activeTag": "active", + "alreadyBound": "Your {{connector}} channel is bound to {{pod}}. Other pods reach it through \"Pods that reach this channel\" — switch a pod on there.", + "ask": "Ask", + "askOperator": "ask your operator", + "attentionHint": "Only escalations and the lead agent reach the channel.", + "availableLine": "Connect {{label}} to a pod.", + "availableSlack": "one click in your workspace", + "availableTelegram": "one message", + "cancel": "Cancel", + "channelDetails": "Channel details", + "channelSees": "What the channel sees", + "channels": "Your channels", + "codeError": "Could not create a new code.", + "codeExpired": "The enable code expired.", + "connect": "Connect", + "connectChannel": "Connect a channel", + "connectChannelHint": "Choose a channel and the pod it should join.", + "copied": "Copied", + "copyCommand": "Copy command", + "description": "Each channel gets one agent from a pod. It answers there in its own name; the rest of the team stays behind it.", + "errorLine": "The connection dropped.", + "errorReconnect": "reconnect to resume", + "gateLead": "Lead agent for {{pod}}", + "gateMode": "Mode for {{pod}}", + "gateOff": "off", + "gateSwitch": "Relay {{pod}}", + "gatesTitle": "Pods that reach this channel", + "installInProgress": "Still setting up — try again in a moment.", + "installInProgressForPod": "Still setting up for {{pod}} — try again in a moment.", + "leadAny": "Any agent leads", + "makeActive": "Make active", + "manage": "Manage", + "mirrorHint": "Every agent message reaches the channel.", + "mode": "Relay mode", + "modeAttention": "Attention", + "modeDefault": "Default", + "modeMirror": "Mirror", + "newCode": "New code", + "nextStep": "Next step", + "noPods": "Join a pod first.", + "notConnected": "not connected", + "notEnabled": "Not enabled on this instance.", + "notLinkedDetail": "your messages have nowhere to go", + "notLinkedLine": "{{title}} · not linked to a pod", + "notRelaying": "not relaying", + "notYetLine": "Not yet. Tell us which channel you need and we build it next.", + "nothingSent": "nothing was sent", + "pausedEyebrow": "Paused", + "pausedLine": "Paused by an administrator.", + "pausedNote": "Only an administrator can resume it. Your relay, mode and pods are kept.", + "pausedRefusal": "An administrator paused this connector. Ask your operator.", + "pausedShort": "Paused.", + "pickPod": "Pick a pod", + "pickWhere": "Pick where your messages go", + "provider": "Channel provider", + "providerNotConfigured": "{{connector}} is not enabled on this instance. Ask your operator.", + "recordGone": "The channel record is gone.", + "relayOffHint": "Messages stay in the pod.", + "remove": "Remove", + "removeConfirm": "Really remove — the channel goes quiet", + "removeError": "Could not remove the channel.", + "removeSlow": "Removal is taking longer than it should.", + "removing": "Removing…", + "retry": "Retry", + "retryOrRemove": "retry, or remove it", + "retryRebuilds": "retry rebuilds it", + "retryRemove": "Retry remove", + "serverLetGo": "the server let go of it", + "settingUp": "Setting up…", + "setupFailed": "Setup didn’t finish.", + "setupSlow": "Setup is taking longer than it should.", + "showCode": "Show code", + "slackAuthorize": "Authorize in Slack", + "slackAuthorizeDetail": "one click in Slack", + "slackAuthorizeHint": "Authorize Commonly in your Slack workspace to connect your DM.", + "slackAuthorizing": "Opening Slack…", + "slackCallbackError": "Slack authorization didn’t complete — try again.", + "slackConfirm": "Confirm", + "slackConfirmConnection": "Confirm connection", + "slackConfirmDetail": "waiting for you to confirm", + "slackConfirmHint": "{{workspace}} wants to connect as {{user}}.", + "slackConfirmRow": "{{workspace}} says {{user}} connected — is that you?", + "slackReject": "This is not me", + "title": "Connectors", + "viewChannel": "View {{channel}}", + "waitingServer": "waiting for the server", + "linkedLine": "{{title}} · linked to {{pod}}", + "waitingToConnect": "{{label}} is waiting to connect.", + "enableCommand": "/commonly-enable {{code}}" }, "artifacts": { "title": "Artifacts", @@ -1920,7 +2009,96 @@ "loadFailed": "Could not load artifacts." }, "tools": { - "grantedAge": "granted {{age}}" + "grantedAge": "granted {{age}}", + "aPod": "a pod", + "aSeat": "an agent", + "add": "Add", + "addTool": "Add {{tool}}", + "adminSetup": "administrator setup", + "agents": "agents", + "agentsAllowed": "agents allowed", + "ask": "Ask", + "asksEveryWrite": "every write asks first", + "asksFirst": "asks a person first", + "asksList": "{{tools}} ask first", + "asksNothing": "nothing asks first", + "awaiting": "awaiting a person", + "budget": "budget", + "budgetTotal": "{{calls}} calls", + "budgetWindow": "{{calls}} calls per {{window}}", + "by": "by", + "calls": "calls", + "cancel": "Cancel", + "changeAccess": "Change access", + "connection": "connection", + "counts": "Call counts", + "days": "in {{count}} days", + "endedExpired": "Expired {{rel}}.", + "endedRevoked": "Revoked {{rel}}.", + "endedRevokedBy": "Revoked by {{member}} {{rel}}.", + "ends": "ends", + "endsRel": "Ends {{rel}}.", + "expiredLine": "expired {{rel}}", + "filter": "Show", + "githubAppError": "Could not install the GitHub App.", + "githubAppOwnedByOther": "This GitHub App is already installed by another administrator.", + "githubAppRequired": "Enter the installation ID, owner, and repository.", + "githubAppSetupHint": "Connect the GitHub App once so people can grant GitHub tools to their rooms.", + "grant": "grant", + "grantAct": "Grant", + "grantAgain": "Grant again", + "grantDetails": "Grant details", + "grantError": "Could not grant it.", + "grantedByOn": "Granted by {{member}} {{rel}}.", + "grantedCount": "{{count}} granted", + "grantedOn": "Granted {{rel}}.", + "grantedTo": "granted to", + "granting": "Granting…", + "installGitHubApp": "Install GitHub App", + "installationId": "installation ID", + "installingGitHubApp": "Installing…", + "keep": "Keep it", + "loading": "Loading tools…", + "manage": "Manage", + "mayUse": "{{agents}} may use it", + "mode": "what it may do", + "modeRead": "read", + "modeWrite": "read and write", + "modeWriteConfirm": "read and write, ask first", + "moreCount": "{{count}} more", + "noConnection": "install the GitHub App first · an admin does this once", + "noSeats": "no agent in this room yet", + "noTools": "no tools on the allow-list", + "nobody": "no agent", + "notEnabled": "not enabled on this instance · ask your operator", + "notGranted": "not granted", + "nothingMatches": "Nothing matches.", + "outcomeFailed": "failed", + "outcomeOk": "ok", + "outcomeRefused": "refused", + "outcomeSuperseded": "answered", + "owner": "owner", + "readOrWrite": "read, or read and write", + "refused": "refused", + "repository": "repository", + "revoke": "Revoke", + "revokeConfirm": "Yes, revoke it", + "revokeError": "Could not revoke the grant.", + "revokeOld": "Revoke the old grant", + "revokedByLine": "revoked by {{member}} {{rel}}", + "revokedLine": "revoked {{rel}}", + "search": "Search tools", + "segAll": "All", + "segGranted": "Granted", + "segNotYet": "Not yet", + "staleAfterChange": "The new grant is live. This old one still is too — its revoke did not go through.", + "title": "Tools", + "toRoom": "room", + "trail": "trail", + "trailEmpty": "No calls yet.", + "trailError": "Could not read the trail.", + "viewGrant": "View {{tool}} in {{pod}}", + "listSeparator": ", " }, "time": { "age": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2c25896f6..b2a83614d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1875,7 +1875,96 @@ "since": "{{age}}前起", "paused": "{{age}}前暂停", "slackAnswered": "Slack 于{{age}}前回应" - } + }, + "activeTag": "当前", + "alreadyBound": "你的 {{connector}} 频道已绑定到 {{pod}}。其他 Pod 通过「接入此频道的 Pod」接入——在那里把某个 Pod 打开即可。", + "ask": "申请", + "askOperator": "联系实例管理员", + "attentionHint": "只有需要关注的消息和主导智能体的消息会发送到频道。", + "availableLine": "将 {{label}} 连接到 Pod。", + "availableSlack": "在你的工作空间中点一下", + "availableTelegram": "发一条消息", + "cancel": "取消", + "channelDetails": "频道详情", + "channelSees": "频道可见内容", + "channels": "你的频道", + "codeError": "无法生成新的启用码。", + "codeExpired": "启用码已过期。", + "connect": "连接", + "connectChannel": "连接频道", + "connectChannelHint": "选择频道,以及它要加入的 Pod。", + "copied": "已复制", + "copyCommand": "复制命令", + "description": "每个频道从一个 Pod 接入 1 个智能体。它会在那里以自己的名义回复,其余团队成员都在幕后。", + "errorLine": "连接已断开。", + "errorReconnect": "重新连接以恢复", + "gateLead": "{{pod}} 的主导智能体", + "gateMode": "{{pod}} 的模式", + "gateOff": "已关闭", + "gateSwitch": "转发 {{pod}}", + "gatesTitle": "接入此频道的 Pod", + "installInProgress": "仍在设置中,请稍后再试。", + "installInProgressForPod": "{{pod}} 仍在设置中,请稍后再试。", + "leadAny": "任一智能体都可主导", + "makeActive": "设为当前", + "manage": "管理", + "mirrorHint": "每条智能体消息都会发送到频道。", + "mode": "转发模式", + "modeAttention": "提醒", + "modeDefault": "默认", + "modeMirror": "镜像", + "newCode": "新启用码", + "nextStep": "下一步", + "noPods": "请先加入一个 Pod。", + "notConnected": "未连接", + "notEnabled": "此实例尚未启用。", + "notLinkedDetail": "你的消息还没有去处", + "notLinkedLine": "{{title}} · 未连接到 Pod", + "notRelaying": "未转发", + "notYetLine": "还没有。告诉我们你需要哪个频道,我们下一个就做。", + "nothingSent": "尚未发送任何内容", + "pausedEyebrow": "已暂停", + "pausedLine": "管理员已暂停。", + "pausedNote": "只有管理员可以恢复。你的转发、模式和 Pod 都会保留。", + "pausedRefusal": "管理员已暂停此连接器,请联系实例管理员。", + "pausedShort": "已暂停。", + "pickPod": "选择 Pod", + "pickWhere": "选择消息发往哪里", + "provider": "频道提供商", + "providerNotConfigured": "此实例尚未启用 {{connector}},请联系实例管理员。", + "recordGone": "频道记录已不存在。", + "relayOffHint": "消息会留在 Pod 中。", + "remove": "移除", + "removeConfirm": "确定要移除吗?频道将停止接收消息", + "removeError": "无法移除频道。", + "removeSlow": "移除时间超出预期。", + "removing": "移除中…", + "retry": "重试", + "retryOrRemove": "重试,或移除它", + "retryRebuilds": "重试即可重建", + "retryRemove": "重试移除", + "serverLetGo": "服务器已释放该连接", + "settingUp": "设置中…", + "setupFailed": "设置未完成。", + "setupSlow": "设置时间超出预期。", + "showCode": "显示启用码", + "slackAuthorize": "在 Slack 中授权", + "slackAuthorizeDetail": "在 Slack 中点一下", + "slackAuthorizeHint": "在你的 Slack 工作空间中授权 Commonly,以连接你的私信。", + "slackAuthorizing": "正在打开 Slack…", + "slackCallbackError": "Slack 授权未完成,请重试。", + "slackConfirm": "确认", + "slackConfirmConnection": "确认连接", + "slackConfirmDetail": "等待你确认", + "slackConfirmHint": "{{workspace}} 想以 {{user}} 的身份连接。", + "slackConfirmRow": "{{workspace}} 显示 {{user}} 已连接,是你吗?", + "slackReject": "这不是我", + "title": "连接器", + "viewChannel": "查看 {{channel}}", + "waitingServer": "等待服务器响应", + "linkedLine": "{{title}} · 已连接到 {{pod}}", + "waitingToConnect": "{{label}} 正在等待连接。", + "enableCommand": "/commonly-enable {{code}}" }, "artifacts": { "title": "产物", @@ -1912,7 +2001,96 @@ "loadFailed": "无法加载产物。" }, "tools": { - "grantedAge": "{{age}}前授予" + "grantedAge": "{{age}}前授予", + "aPod": "一个 Pod", + "aSeat": "一个智能体", + "add": "添加", + "addTool": "添加 {{tool}}", + "adminSetup": "管理员设置", + "agents": "智能体", + "agentsAllowed": "可使用的智能体", + "ask": "申请", + "asksEveryWrite": "每次写入前都需确认", + "asksFirst": "调用前需人工确认", + "asksList": "{{tools}} 调用前需确认", + "asksNothing": "无需确认", + "awaiting": "等待人工确认", + "budget": "额度", + "budgetTotal": "{{calls}} 次调用", + "budgetWindow": "每 {{window}} 可调用 {{calls}} 次", + "by": "授权者", + "calls": "调用", + "cancel": "取消", + "changeAccess": "更改访问权限", + "connection": "连接", + "counts": "调用次数", + "days": "{{count}} 天后", + "endedExpired": "{{rel}}过期。", + "endedRevoked": "{{rel}}撤销。", + "endedRevokedBy": "{{rel}}由 {{member}} 撤销。", + "ends": "结束时间", + "endsRel": "{{rel}}结束。", + "expiredLine": "{{rel}}过期", + "filter": "显示", + "githubAppError": "无法安装 GitHub App。", + "githubAppOwnedByOther": "这个 GitHub App 已由另一位管理员安装。", + "githubAppRequired": "请填写安装 ID、所有者和仓库。", + "githubAppSetupHint": "只需连接一次 GitHub App,成员就能把 GitHub 工具授权给自己的 Pod。", + "grant": "授权", + "grantAct": "授权", + "grantAgain": "再次授权", + "grantDetails": "授权详情", + "grantError": "无法授权。", + "grantedByOn": "{{rel}}由 {{member}} 授权。", + "grantedCount": "已授权 {{count}} 个", + "grantedOn": "{{rel}}授权。", + "grantedTo": "授权给", + "granting": "授权中…", + "installGitHubApp": "安装 GitHub App", + "installationId": "安装 ID", + "installingGitHubApp": "安装中…", + "keep": "保留", + "loading": "正在加载工具…", + "manage": "管理", + "mayUse": "{{agents}} 可以使用", + "mode": "可执行的操作", + "modeRead": "读取", + "modeWrite": "读取和写入", + "modeWriteConfirm": "读取和写入,写入前需确认", + "moreCount": "另有 {{count}} 个", + "noConnection": "请先安装 GitHub App · 管理员只需安装一次", + "noSeats": "此 Pod 中还没有智能体", + "noTools": "允许列表中没有工具", + "nobody": "没有智能体", + "notEnabled": "此实例尚未启用 · 联系实例管理员", + "notGranted": "未授权", + "nothingMatches": "没有匹配项。", + "outcomeFailed": "失败", + "outcomeOk": "成功", + "outcomeRefused": "已拒绝", + "outcomeSuperseded": "已回答", + "owner": "所有者", + "readOrWrite": "读取,或读取和写入", + "refused": "拒绝", + "repository": "仓库", + "revoke": "撤销", + "revokeConfirm": "确定,撤销", + "revokeError": "无法撤销授权。", + "revokeOld": "撤销旧授权", + "revokedByLine": "{{rel}}由 {{member}} 撤销", + "revokedLine": "{{rel}}撤销", + "search": "搜索工具", + "segAll": "全部", + "segGranted": "已授权", + "segNotYet": "尚未授权", + "staleAfterChange": "新授权已生效。旧授权也仍然有效——撤销未成功。", + "title": "工具", + "toRoom": "房间", + "trail": "调用记录", + "trailEmpty": "还没有调用。", + "trailError": "无法读取调用记录。", + "viewGrant": "在 {{pod}} 中查看 {{tool}}", + "listSeparator": "、" }, "time": { "age": { diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index be1b577a5..d298640ad 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -107,6 +107,11 @@ interface GithubAppIntegrationResponse { const USED_RECENTLY_MS = 10 * 60 * 1000; const MAX_PODS = 20; const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 }; +// The two option sets the aside renders as buttons. They live here, not inline in the +// JSX, because eslint's i18next/no-literal-string (jsx-only) reads an array literal in +// a JSX expression as copy — and these are ids, not words (TASK-164). +const WRITE_MODES: GrantWriteMode[] = ['read', 'write-with-confirm', 'write']; +const SEGMENTS = ['all', 'granted', 'not-yet'] as const; /** The age as a unit-suffixed number ("23d", "5m", "just now"), from the timestamp and keys. */ export const shortAge = (date: string | null | undefined, now: number, t: (key: string, options?: Record) => string): string => { @@ -296,11 +301,14 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const entry = Object.entries(seats).find(([, rows]) => rows.some((row) => row.userId === grant.target.id)); return entry ? entry[0] : null; }; + // zh joins a list with 、 and en with ', ', so the separator is copy and not a + // literal at each join site (TASK-164). + const joinList = (items: string[]): string => items.join(t('tools.listSeparator', { defaultValue: ', ' })); const audienceLabels = (grant: ToolGrant): string => { const podId = grantPodId(grant); const labels = grant.effectiveAudience.map((id) => seatLabel(podId, id)); if (labels.length === 0) return t('tools.nobody', { defaultValue: 'no agent' }); - return labels.join(', '); + return joinList(labels); }; const irreversibleTools = (entry: ToolCatalogEntry | null, tools: string[]): string[] => (entry?.tools || []) .filter((tool) => tool.irreversible && tools.includes(tool.name)).map((tool) => tool.name); @@ -310,7 +318,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { // Under `write` the floor is the tool's own irreversible flag (piece 2b): the list is the catalogue's. const list = irreversibleTools(entryFor(grant), grant.tools); return list.length - ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: list.join(', ') }) + ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: joinList(list) }) : t('tools.asksNothing', { defaultValue: 'nothing asks first' }); }; const outcomeLabel = (outcome: ToolOutcome): string => ({ @@ -503,7 +511,10 @@ const V2ConnectorTools: React.FC = ({ pods }) => { : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: relativeTime(grant.revokedAt, now) })) : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: relativeTime(grant.expiresAt, now) })) // Direction A rule 1: the write mode is the glyph beside this line; its words ride the 390 kicker. - : `${audienceLabels(grant)} ${t('tools.mayUse', { defaultValue: 'may use it' })}`; + // One interpolated key, not `{{agents}}` + a separate 'may use it': a language + // that orders the clause differently needs the whole sentence (TASK-164). The + // rendered English is unchanged. + : t('tools.mayUse', { defaultValue: '{{agents}} may use it', agents: audienceLabels(grant) }); return (
@@ -855,7 +866,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { onChange={(event) => setQuery(event.target.value)} />
- {(['all', 'granted', 'not-yet'] as const).map((key) => ( + {SEGMENTS.map((key) => ( - {enabled ? `since ${relativeTime(gate?.since, now)}` : t('connectors.gateOff', { defaultValue: 'off' })} + {enabled ? t('connectors.sinceWhen', { defaultValue: 'since {{rel}}', rel: localizeRelativeTime(gate?.since, t, { ...connectorRelativeTimeOptions, now }) }) : t('connectors.gateOff', { defaultValue: 'off' })} { + if (!date) return { kind: 'missing' }; + const timestamp = new Date(date).getTime(); + if (!Number.isFinite(timestamp)) return { kind: 'missing' }; + const elapsed = now - timestamp; + const signedElapsed = includeFuture ? elapsed : Math.max(0, elapsed); + const future = signedElapsed < 0; + const abs = Math.abs(signedElapsed); + const round = rounding === 'floor' ? Math.floor : Math.round; + const minutes = round(abs / 60_000); + if (minutes < 1) return { kind: 'now', future }; + if (minutes < 60) return { kind: 'rel', unit: 'minute', count: minutes, future }; + const hours = round(minutes / 60); + if (hours < 24) return { kind: 'rel', unit: 'hour', count: hours, future }; + return { kind: 'rel', unit: 'day', count: round(hours / 24), future }; +}; + +/** Keep the compact English grammar shared by the connector surfaces. */ +const formatRelativeParts = (parts: RelativeParts, missing: string): string => { + if (parts.kind === 'missing') return missing; + if (parts.kind === 'now') return parts.future ? 'in a moment' : 'just now'; + const unit = parts.unit[0]; + return `${parts.count}${unit} ${parts.future ? 'from now' : 'ago'}`; +}; + +/** + * The English string alone, for surfaces that are not localized (and as the + * `defaultValue` fallback). Grammar is unchanged from the two per-component + * helpers this replaced. + */ +export const relativeTime = ( + date?: string | null, + options: RelativeTimeOptions = {}, +): string => formatRelativeParts(relativeParts(date, options), options.missing ?? '—'); + +/** + * The same parts, rendered through the `time.age.*` keys both connector + * surfaces already share ("2小时前", "刚刚"). A key per unit and direction, so + * every one is a literal the migration manifest can enforce. + */ +export const localizeRelativeTime = ( + date: string | null | undefined, + t: TFunction, + options: RelativeTimeOptions = {}, +): string => { + const parts = relativeParts(date, options); + const missing = options.missing ?? '—'; + const fallback = formatRelativeParts(parts, missing); + if (parts.kind === 'missing') { + return missing === 'just now' ? t('time.age.justNow', { defaultValue: missing }) : missing; + } + if (parts.kind === 'now') { + return parts.future + ? t('time.age.inAMoment', { defaultValue: fallback }) + : t('time.age.justNow', { defaultValue: fallback }); + } + const n = parts.count; + switch (parts.unit) { + case 'minute': + return parts.future + ? t('time.age.minutesFromNow', { n, defaultValue: fallback }) + : t('time.age.minutesAgo', { n, defaultValue: fallback }); + case 'hour': + return parts.future + ? t('time.age.hoursFromNow', { n, defaultValue: fallback }) + : t('time.age.hoursAgo', { n, defaultValue: fallback }); + default: + return parts.future + ? t('time.age.daysFromNow', { n, defaultValue: fallback }) + : t('time.age.daysAgo', { n, defaultValue: fallback }); + } +}; + +/** + * A budget window as a localized duration ("1h" / "1小时"). Reuses the age + * family's unit strings: a window is an age with the direction dropped. + */ +export const localizeWindow = (windowMs: number, t: TFunction): string => { + const minutes = Math.max(1, Math.round(windowMs / 60_000)); + if (minutes < 60) return t('time.age.minutes', { defaultValue: '{{n}}m', n: minutes }); + const hours = Math.round(minutes / 60); + if (hours < 24) return t('time.age.hours', { defaultValue: '{{n}}h', n: hours }); + return t('time.age.days', { defaultValue: '{{n}}d', n: Math.round(hours / 24) }); +}; From 6c827f93f90c48fb4d82bab16eb8c3827bd7c4a4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:51:25 -0700 Subject: [PATCH 5/7] fix(v2): apply Wren's two folds, and stop lowercasing the Pod noun (TASK-164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two zh strings Wren folded at #1717 that neither its head nor this branch carried: the budget window had a stray space before 可 (每 1小时 可调用), and the connectors description read "1 个智能体" where the ruling is 一个. Both are the UX gate's fold request, applied here rather than left as a follow-up because they are one-line catalog values on strings this PR already touches. Also :lang(zh) text-transform: none for the two mono labels that lowercase their Latin — .v2-connector-gates__title and .v2-tools__field's first child/legend. Their zh copy is CJK plus `Pod`, and CSS was rendering it `pod`, contradicting the sentence beside it. Case is not a property of CJK, so the reset is scoped to the label, not the type scale. Presence-guarded in v2-layout-invariants.test.ts, with the two underlying lowercase rules asserted first so the reset cannot pass by guarding a rule that no longer exists; control: renaming the selector reds it, restoring greens it. --- frontend/src/i18n/locales/zh-CN.json | 4 ++-- .../src/v2/__tests__/V2ConnectorTools.test.tsx | 2 +- .../src/v2/__tests__/v2-layout-invariants.test.ts | 12 ++++++++++++ frontend/src/v2/v2.css | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 1efc195dd..0abffcfb0 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1896,7 +1896,7 @@ "connectChannelHint": "选择频道,以及它要加入的 Pod。", "copied": "已复制", "copyCommand": "复制命令", - "description": "每个频道从一个 Pod 接入 1 个智能体。它会在那里以自己的名义回复,其余团队成员都在幕后。", + "description": "每个频道从一个 Pod 接入一个智能体。它会在那里以自己的名义回复,其余团队成员都在幕后。", "errorLine": "连接已断开。", "errorReconnect": "重新连接以恢复", "gateLead": "{{pod}} 的主导智能体", @@ -2018,7 +2018,7 @@ "awaiting": "等待人工确认", "budget": "额度", "budgetTotal": "{{calls}} 次调用", - "budgetWindow": "每 {{window}} 可调用 {{calls}} 次", + "budgetWindow": "每 {{window}}可调用 {{calls}} 次", "by": "授权者", "calls": "调用", "cancel": "取消", diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 82d8d03dd..5b7eb4a0a 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -194,7 +194,7 @@ test('TASK-164: the grant, budget and trail times read in zh-CN, not English', a const aside = await screen.findByRole('complementary'); expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument(); expect(within(aside).getByText(/后结束。/)).toBeInTheDocument(); - expect(within(aside).getByText(/每 1小时 可调用 50 次/)).toBeInTheDocument(); + expect(within(aside).getByText(/每 1小时可调用 50 次/)).toBeInTheDocument(); await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4)); const lines = within(aside).getAllByRole('listitem'); expect(lines.map((line) => line.textContent)).toEqual([ diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index f20f47ed4..889b9431a 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1714,6 +1714,18 @@ describe('v2 layout invariants (CSS rule presence)', () => { } }); + test('zh-CN: the mono labels that carry the Pod noun are not lowercased (TASK-164)', () => { + // The two rules this resets BOTH still lowercase — assert them first, so + // the reset cannot pass by guarding a rule that no longer exists. + expect(ruleBody(v2, '.v2-connector-gates__title')).toContain('text-transform: lowercase'); + expect(ruleBody(v2, '.v2-tools__field > span:first-child, .v2-tools__field > legend')).toContain('text-transform: lowercase'); + const start = v2.indexOf('.v2-root:lang(zh) .v2-connector-gates__title'); + expect(start).toBeGreaterThan(-1); + const block = v2.slice(start, v2.indexOf('}', start)); + expect(block).toContain('.v2-root:lang(zh) .v2-tools__field > span:first-child'); + expect(block).toContain('text-transform: none'); + }); + test('zh-CN: body copy takes line-height 1.6 and a 12px floor under :lang(zh) (TASK-055)', () => { // Measured in a real browser: .v2-msg__content rendered Chinese at 1.55, // the composer hint at 1.45/11px. CJK glyphs fill the em box, so Latin diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index ffeeefe59..551b1b6cf 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -9808,6 +9808,21 @@ body.modern-ui.v2-canvas { letter-spacing: 0; } +/* --------------------------------------------------------------------------- + * zh-CN: mono labels drop `text-transform: lowercase` (TASK-164, rule class 3). + * `.v2-connector-gates__title` and `.v2-tools__field > span:first-child` set + * lowercase for Latin kickers. The zh copy for both is CJK plus one Latin + * token — `Pod`, the product noun, which lily-shen ruled is never a synonym — + * and CSS lowercases it to `pod`, so a label contradicts the sentence beside + * it. Case is not a property of CJK; the reset exists only for the Latin token + * the copy carries. Guarded by v2-layout-invariants.test.ts. + * ------------------------------------------------------------------------ */ +.v2-root:lang(zh) .v2-connector-gates__title, +.v2-root:lang(zh) .v2-tools__field > span:first-child, +.v2-root:lang(zh) .v2-tools__field > legend { + text-transform: none; +} + /* --------------------------------------------------------------------------- * zh-CN: CJK body line-height 1.6 and a 12px floor (TASK-055, rule class 2). * Latin body copy sits at 1.45–1.55 here; CJK glyphs fill the em box, so the From 79f5cb48d7613beb06e318cf608197d383e5d14f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:00:42 -0700 Subject: [PATCH 6/7] test(v2): pin localizeWindow's minute clamp (TASK-164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint-review's code gate found the one line in this PR with no test: dropping `Math.max(1, …)` in localizeWindow passed the whole suite (232 green) while changing what a sub-minute grant window renders. Covered at the unit rather than through a component whose fixture happens to use a one-hour window. The killing case is not the one proposed: 30000ms is exactly 0.5 minutes and `Math.round(0.5) === 1`, so it survives the mutation. 1ms is the case that does not — measured, not argued: clamp removed → `0m` / `0分钟`, and this file is the only red (1 failed / 2 passed). --- .../src/v2/utils/localizeRelativeTime.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frontend/src/v2/utils/localizeRelativeTime.test.ts diff --git a/frontend/src/v2/utils/localizeRelativeTime.test.ts b/frontend/src/v2/utils/localizeRelativeTime.test.ts new file mode 100644 index 000000000..4b890ad7b --- /dev/null +++ b/frontend/src/v2/utils/localizeRelativeTime.test.ts @@ -0,0 +1,51 @@ +import i18n, { i18nReady } from '../../i18n'; +import { localizeWindow } from './localizeRelativeTime'; + +// localizeWindow had no test of its own, and the clamp inside it is the ONE +// behaviour change in TASK-164: the English path used to read `just now` for a +// sub-minute window. Removing the clamp passes every other test in the repo and +// renders `0m` / `0分钟` instead — worse than either string it replaced — so the +// clamp is pinned here directly, at the unit rather than through a component +// whose fixture happens to use a one-hour window. +describe('localizeWindow', () => { + beforeAll(async () => { + await i18nReady; + }); + + afterAll(async () => { + await i18n.changeLanguage('en'); + }); + + const minutes = async (ms: number) => { + await i18n.changeLanguage('en'); + const en = localizeWindow(ms, i18n.t.bind(i18n)); + await i18n.changeLanguage('zh-CN'); + const zh = localizeWindow(ms, i18n.t.bind(i18n)); + return { en, zh }; + }; + + test('a sub-minute window floors at one minute instead of rendering 0 (TASK-164)', async () => { + // 30s is reachable: RoomGrant.budget.windowMs permits a minimum of 1. + await expect(minutes(30_000)).resolves.toEqual({ en: '1m', zh: '1分钟' }); + // And the bottom of that range must not produce a zero-width window. + await expect(minutes(1)).resolves.toEqual({ en: '1m', zh: '1分钟' }); + }); + + test('rounds to the nearest minute, hour and day', async () => { + await expect(minutes(60_000)).resolves.toEqual({ en: '1m', zh: '1分钟' }); + await expect(minutes(90_000)).resolves.toEqual({ en: '2m', zh: '2分钟' }); + await expect(minutes(3_600_000)).resolves.toEqual({ en: '1h', zh: '1小时' }); + await expect(minutes(5_400_000)).resolves.toEqual({ en: '2h', zh: '2小时' }); + await expect(minutes(23 * 3_600_000)).resolves.toEqual({ en: '23h', zh: '23小时' }); + await expect(minutes(24 * 3_600_000)).resolves.toEqual({ en: '1d', zh: '1天' }); + await expect(minutes(3 * 24 * 3_600_000)).resolves.toEqual({ en: '3d', zh: '3天' }); + }); + + test('is localized rather than hardcoded, in both directions', async () => { + await i18n.changeLanguage('zh-CN'); + // No Latin unit can survive: this is what the zh UX gate caught. + expect(localizeWindow(3_600_000, i18n.t.bind(i18n))).not.toMatch(/[a-z]/i); + await i18n.changeLanguage('en'); + expect(localizeWindow(3_600_000, i18n.t.bind(i18n))).toMatch(/[a-z]/); + }); +}); From 3d6e1763a13cc5b0e5d8aa801a765159efa5f574 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:03:22 -0700 Subject: [PATCH 7/7] zh-CN: drop the stray space in tools.days (TASK-164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ux-lead's post-gate note: the 结束时间 select offered 「7 天后」 beside 「3天后结束。」 in the same panel. tools.days was the ONE spaced value among this PR's 186 added zh keys (6 unspaced: the new time.age.* family), because it was lifted verbatim from #1717 rather than spelling the construction this PR ships. Same construction, one spelling. Not decided here and measured separately: on main the spaced form is the MAJORITY for number+unit (31 keys, e.g. inviteModal.options.days7 "7 天", podChat.catchup.hoursAgo) against 4 unspaced (this PR's three time.age.* keys plus yourTeam.card.age.days). So the new family is the minority style; that is lily-shen's call as a catalog-wide question, and this commit only stops this PR from shipping two spellings of one phrase. --- frontend/src/i18n/locales/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 0abffcfb0..f278a8d40 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2025,7 +2025,7 @@ "changeAccess": "更改访问权限", "connection": "连接", "counts": "调用次数", - "days": "{{count}} 天后", + "days": "{{count}}天后", "endedExpired": "{{rel}}过期。", "endedRevoked": "{{rel}}撤销。", "endedRevokedBy": "{{rel}}由 {{member}} 撤销。",