diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 97ef11692..d543067f5 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -65,6 +65,12 @@ 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', + 'src/v2/utils/localizeRelativeTime.ts', ], rules: { 'i18next/no-literal-string': ['error', { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a66baa26e..11af3d3b9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1877,13 +1877,103 @@ "rowAttention": "escalations reach the channel", "rowMirror": "every agent line reaches the channel", "rowRelayOff": "messages stay in the pod", + "sinceWhen": "since {{rel}}", "age": { "added": "added {{age}}", "started": "started {{age}}", "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,11 +2010,107 @@ "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 pods.", + "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 pod 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": "pod", + "trail": "trail", + "trailEmpty": "No calls yet.", + "trailError": "Could not read the trail.", + "viewGrant": "View {{tool}} in {{pod}}", + "listSeparator": ", " }, "time": { "age": { "justNow": "just now", + "inAMoment": "in a moment", + "minutesAgo": "{{n}}m ago", + "hoursAgo": "{{n}}h ago", + "daysAgo": "{{n}}d ago", + "minutesFromNow": "{{n}}m from now", + "hoursFromNow": "{{n}}h from now", + "daysFromNow": "{{n}}d from now", "minutes": "{{n}}m", "hours": "{{n}}h", "days": "{{n}}d" diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2c25896f6..f278a8d40 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1869,13 +1869,103 @@ "rowAttention": "升级消息会回到频道", "rowMirror": "智能体的每条消息都会回到频道", "rowRelayOff": "消息只留在 Pod 内", + "sinceWhen": "{{rel}}起", "age": { "added": "{{age}}前添加", "started": "{{age}}前开始", "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 接入一个智能体。它会在那里以自己的名义回复,其余团队成员都在幕后。", + "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,11 +2002,107 @@ "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": "Pod", + "trail": "调用记录", + "trailEmpty": "还没有调用。", + "trailError": "无法读取调用记录。", + "viewGrant": "在 {{pod}} 中查看 {{tool}}", + "listSeparator": "、" }, "time": { "age": { "justNow": "刚刚", + "inAMoment": "即将", + "minutesAgo": "{{n}}分钟前", + "hoursAgo": "{{n}}小时前", + "daysAgo": "{{n}}天前", + "minutesFromNow": "{{n}}分钟后", + "hoursFromNow": "{{n}}小时后", + "daysFromNow": "{{n}}天后", "minutes": "{{n}}分钟", "hours": "{{n}}小时", "days": "{{n}}天" diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index bcf160992..5b7eb4a0a 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -7,6 +7,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import { MemoryRouter } from 'react-router-dom'; import V2ConnectorTools from '../components/V2ConnectorTools'; import { AuthContext } from '../../context/AuthContext'; +import i18n, { i18nReady } from '../../i18n'; jest.mock('axios', () => { const mock = { @@ -181,6 +182,35 @@ test('rows carry the states table: a live grant pulses when used in the last 10 expect(document.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · granted /); }); +// TASK-164: every {{rel}} in these lines used to be filled with an English +// relative time, so a zh grant read "Granted by sam 1小时前." worst of both. +test('TASK-164: the grant, budget and trail times read in zh-CN, not English', async () => { + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + const { container } = renderTools(); + await screen.findByText(/^Tools$|^工具$/); + fireEvent.click(container.querySelector('.v2-connector-row__selection') as HTMLElement); + const aside = await screen.findByRole('complementary'); + expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument(); + expect(within(aside).getByText(/后结束。/)).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([ + 'Scout · github.list_issues · 成功2分钟前', + 'Scout · github.comment_on_issue · 已拒绝5分钟前', + 'Scout · github.comment_on_issue · 等待人工确认7分钟前', + 'Scout · github.close_issue · 已回答8分钟前', + ]); + // The whole aside, not just the four line keys: this is the regression the + // gate caught — an English relative time inside an otherwise-Chinese line. + expect(aside.textContent).not.toMatch(/\bago\b|from now|\bm ago\b/); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } +}); + test('the aside reads the grant and the trail: agents, allow-list under its mode, what asks first, three counts, outcomes as words', async () => { renderTools(); fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 94d90b450..bbc7a6916 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage'; import { AuthContext } from '../../context/AuthContext'; import en from '../../i18n/locales/en.json'; +import i18n, { i18nReady } from '../../i18n'; jest.mock('axios', () => { const mock = { @@ -1079,6 +1080,27 @@ describe('V2ConnectorsPage', () => { )); }); + // TASK-164: the gate line was the last raw English relative time on this page — + // `since ${relativeTime(...)}` built its own sentence and no key could reach it. + it('the gate line reads in zh-CN, not English (TASK-164)', async () => { + mockCatalog([entry({ + installableId: 'slack', + label: 'Slack', + installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, + integration: liveIntegration(), + })]); + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + renderPage(); + const line = await screen.findByText(/前起$/); + expect(line.textContent).toMatch(/^(刚刚|\d+(分钟|小时|天)前)起$/); + expect(screen.queryByText(/^since /)).toBeNull(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + it('lists every pod with its gate, marks the active pod, and writes the whole gates map on a switch', async () => { mockCatalog([entry({ installableId: 'slack', 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/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index be1b577a5..06187669d 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; import { useRelativeNow } from '../hooks/useRelativeNow'; +import { localizeRelativeTime, localizeWindow } from '../utils/localizeRelativeTime'; import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; import { PlatformGlyph } from '../icons/platforms'; @@ -107,6 +108,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 => { @@ -120,20 +126,10 @@ export const shortAge = (date: string | null | undefined, now: number, t: (key: return t('time.age.days', { defaultValue: '{{n}}d', n: Math.floor(hours / 24) }); }; -export const relativeTime = (date?: string | null, now: number = Date.now()): string => { - if (!date) return '—'; - const ms = 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 hours = Math.round(minutes / 60); - if (hours < 24) return `${hours}h ${suffix}`; - const days = Math.round(hours / 24); - return `${days}d ${suffix}`; -}; +// Both surfaces used to carry their own copy of this grammar; the locale family +// lives in one place now (TASK-164). Re-exported so the module's surface is +// unchanged for anything importing it from here. +export { localizeRelativeTime, relativeTime } from '../utils/localizeRelativeTime'; const isExpired = (grant: ToolGrant, now = Date.now()): boolean => new Date(grant.expiresAt).getTime() <= now; // Callers that decide what a render SHOWS pass that render's `now`; the read @@ -296,11 +292,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 +309,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 => ({ @@ -499,11 +498,14 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const line2 = dead ? (grant.revokedAt ? (revokedBy - ? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: relativeTime(grant.revokedAt, now) }) - : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: relativeTime(grant.revokedAt, now) })) - : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: relativeTime(grant.expiresAt, now) })) + ? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: localizeRelativeTime(grant.revokedAt, t, { now }) }) + : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: localizeRelativeTime(grant.revokedAt, t, { now }) })) + : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: localizeRelativeTime(grant.expiresAt, t, { 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 (
@@ -698,7 +700,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => {
{t('tools.agents', { defaultValue: 'agents' })} - {podSeats.length === 0 && {t('tools.noSeats', { defaultValue: 'no agent in this room yet' })}} + {podSeats.length === 0 && {t('tools.noSeats', { defaultValue: 'no agent in this pod yet' })}} {podSeats.map((seat) => seat.userId && (