From 9e4de7027bee865d6a10111d14ec4a281971a8df Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:33:33 -0700 Subject: [PATCH 1/3] i18n(tools): order the grant line by language, and give an empty audience its own line (TASK-179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three shapes ux-lead's zh gate left open at #1981 (their 22:10 update), with lily-shen's wording: - The grant line was three pieces glued at the call site (`granted to` + pod + `by` + member), so zh could only read 「授权给 Growth 授权者 sam」. It is now one key per sentence through Trans, with the two as components, so the language orders the names: 「由 sam 授权给 Launch pod」. `tools.grantedTo` and `tools.by` are retired — those two sites were their only readers. - The aside joined its two sentences with a literal `{' '}`, which zh rendered as 「…授权。 3天后」. It is now `tools.sentenceSeparator` (en ' ', zh ''), the same shape as `tools.listSeparator`, so zh concatenates punctuated sentences with nothing between them. - An empty audience reused `{{agents}} may use it` with an empty subject, which reads 「没有智能体 可以使用」. It selects `tools.nobodyMayUse` instead. The interpolated key keeps its space before a Latin name (「sam 可以使用」). Copy rulings from lily-shen (her 22:29 note): the product noun is pod, never a synonym, so `landing.useCases.browse.text` says pods in en (zh already did), `firstRun.title` is "Bring your agent into your pod" / 「把你的智能体请进你的 Pod」, and `compare.closeUs` is one pod / 「同一个 Pod 里」. `landing.hero.lede` is left alone in both languages — "all in one room" is approved hero copy and 同一个群 is its idiomatic zh. `podChat.agentRoomLiveness.timedOut` follows its zh 此对话中: "Your message is still in this conversation." Proof: V2ConnectorTools gains a TASK-179 case that reads each shape in zh and en. It is load-bearing — reverting the zh grant-line order, the separator, or the empty-audience key each reds it (three mutations run, each 1 failed). The full frontend suite is 115 suites / 1018 tests green, lint 0 errors, tsc --noEmit exit 0. --- frontend/src/i18n/locales/en.json | 16 ++++--- frontend/src/i18n/locales/zh-CN.json | 12 +++--- .../v2/__tests__/V2ConnectorTools.test.tsx | 43 +++++++++++++++++++ .../src/v2/__tests__/V2FirstRunHero.test.tsx | 32 +++++++------- .../src/v2/components/V2ConnectorTools.tsx | 24 ++++++++--- 5 files changed, 93 insertions(+), 34 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 11af3d3b9..7f42d324a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -316,7 +316,7 @@ }, "firstRun": { "eyebrow": "Your first five minutes", - "title": "Bring your agent into the room", + "title": "Bring your agent into your pod", "lede": "Connect Claude Code, Cursor, or Codex, then start a private conversation without leaving your workspace.", "skip": "Skip for now", "reopen": "Guide", @@ -422,7 +422,7 @@ "goneDark": "{{agentName}} hasn't checked in recently. A reply may take longer.", "unknown": "{{agentName}}'s availability is unknown. A reply may take longer.", "waiting": "Waiting for {{agentName}} to reply…", - "timedOut": "{{agentName}} hasn't replied yet. Your message is still in this room." + "timedOut": "{{agentName}} hasn't replied yet. Your message is still in this conversation." }, "typing": { "one": "{{name}} is thinking…", @@ -785,7 +785,7 @@ }, "browse": { "title": "Browse before you join", - "text": "Discover rooms and the agents in them before you commit to the conversation." + "text": "Discover pods and the agents in them before you commit to the conversation." }, "marketplace": { "title": "Install agents & apps", @@ -1434,7 +1434,7 @@ "usTitle": "What we do differently", "closeTitle": "The honest version", "close": "Want a hosted product and don't mind a closed coordination layer? Raft is good, and shipping. Already committed to Nostr, or want Block's distribution behind you? Buzz is excellent. Tracking work as issues rather than conversation? Multica is the better shape.", - "closeUs": "Want agents from different vendors to be first-class members of one room, on infrastructure you can own outright, with no per-agent tax? That's Commonly.", + "closeUs": "Want agents from different vendors to be first-class members of one pod, on infrastructure you can own outright, with no per-agent tax? That's Commonly.", "note": "Comparison reflects each product's public positioning and published documents at time of writing. Product names are trademarks of their respective owners; this page is not affiliated with or endorsed by any of them.", "nav": { "primaryLabel": "Primary", @@ -2027,7 +2027,6 @@ "budget": "budget", "budgetTotal": "{{calls}} calls", "budgetWindow": "{{calls}} calls per {{window}}", - "by": "by", "calls": "calls", "cancel": "Cancel", "changeAccess": "Change access", @@ -2053,7 +2052,8 @@ "grantedByOn": "Granted by {{member}} {{rel}}.", "grantedCount": "{{count}} granted", "grantedOn": "Granted {{rel}}.", - "grantedTo": "granted to", + "grantedToPod": "granted to {{pod}}", + "grantedToPodBy": "granted to {{pod}} by {{member}}", "granting": "Granting…", "installGitHubApp": "Install GitHub App", "installationId": "installation ID", @@ -2071,6 +2071,7 @@ "noSeats": "no agent in this pod yet", "noTools": "no tools on the allow-list", "nobody": "no agent", + "nobodyMayUse": "nobody may use it", "notEnabled": "not enabled on this instance · ask your operator", "notGranted": "not granted", "nothingMatches": "Nothing matches.", @@ -2099,7 +2100,8 @@ "trailEmpty": "No calls yet.", "trailError": "Could not read the trail.", "viewGrant": "View {{tool}} in {{pod}}", - "listSeparator": ", " + "listSeparator": ", ", + "sentenceSeparator": " " }, "time": { "age": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index f278a8d40..0603ff4be 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -316,7 +316,7 @@ }, "firstRun": { "eyebrow": "最初的五分钟", - "title": "把你的智能体请进房间", + "title": "把你的智能体请进你的 Pod", "lede": "接入 Claude Code、Cursor 或 Codex,然后直接在工作空间中开始一对一私信。", "skip": "暂时跳过", "reopen": "指南", @@ -1289,7 +1289,7 @@ "usTitle": "我们不一样的地方", "closeTitle": "实话实说", "close": "想要托管产品、也不介意协调层是闭源的?Raft 很好,而且已经在跑。已经选定 Nostr,或希望有 Block 的分发能力支撑?Buzz 非常出色。习惯用 issue 而不是对话来跟踪工作?Multica 的形态更合适。", - "closeUs": "希望来自不同厂商的智能体,在同一个房间里都是一等公民,跑在你完全拥有的基础设施上,且不按智能体收费?那就是 Commonly。", + "closeUs": "希望来自不同厂商的智能体,在同一个 Pod 里都是一等公民,跑在你完全拥有的基础设施上,且不按智能体收费?那就是 Commonly。", "note": "本对比基于各产品在撰写时的公开定位与公开文档。各产品名称归其各自所有者所有;本页面与它们无关联,也未获其背书。", "nav": { "primaryLabel": "主导航", @@ -2019,7 +2019,6 @@ "budget": "额度", "budgetTotal": "{{calls}} 次调用", "budgetWindow": "每 {{window}}可调用 {{calls}} 次", - "by": "授权者", "calls": "调用", "cancel": "取消", "changeAccess": "更改访问权限", @@ -2045,7 +2044,8 @@ "grantedByOn": "{{rel}}由 {{member}} 授权。", "grantedCount": "已授权 {{count}} 个", "grantedOn": "{{rel}}授权。", - "grantedTo": "授权给", + "grantedToPod": "授权给 {{pod}}", + "grantedToPodBy": "由 {{member}} 授权给 {{pod}}", "granting": "授权中…", "installGitHubApp": "安装 GitHub App", "installationId": "安装 ID", @@ -2063,6 +2063,7 @@ "noSeats": "此 Pod 中还没有智能体", "noTools": "允许列表中没有工具", "nobody": "没有智能体", + "nobodyMayUse": "没有智能体可以使用", "notEnabled": "此实例尚未启用 · 联系实例管理员", "notGranted": "未授权", "nothingMatches": "没有匹配项。", @@ -2091,7 +2092,8 @@ "trailEmpty": "还没有调用。", "trailError": "无法读取调用记录。", "viewGrant": "在 {{pod}} 中查看 {{tool}}", - "listSeparator": "、" + "listSeparator": "、", + "sentenceSeparator": "" }, "time": { "age": { diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 5b7eb4a0a..06293e79b 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -211,6 +211,49 @@ test('TASK-164: the grant, budget and trail times read in zh-CN, not English', a } }); +test('TASK-179: the grant line is one sentence per language, and an empty audience is its own sentence', async () => { + // ux-lead's render at 3d6e1763 read the old three-piece composition as + // 「授权给 Growth 授权者 sam」, and the empty audience as 「没有智能体 可以使用」. + const emptyAudience = { ...grantLive, grantId: 'grant_empty', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [] }; + axios.get.mockImplementation((url) => { + if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); + if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { podId: 'p1', grants: [grantLive] } }); + if (url === '/api/pods/p2/grants') return Promise.resolve({ data: { podId: 'p2', grants: [emptyAudience] } }); + if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); + if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [] } }); + if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); + return Promise.reject(new Error(`unmocked ${url}`)); + }); + renderTools(); + + const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); + const ops = await screen.findByRole('button', { name: 'View GitHub in Ops' }); + // One key holds both names, so the language orders them. en is unchanged from + // the three-piece version; zh puts the granter first, as the ruling requires. + expect(live.querySelector('strong')?.textContent).toBe('granted to Launch pod by sam'); + expect(within(ops).getByText('nobody may use it')).toBeInTheDocument(); + + fireEvent.click(live); + await screen.findByRole('complementary', { name: 'Grant details' }); + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + // The accessible name is translated too, so locate rows by what they say. + const rows = () => Array.from(document.querySelectorAll('.v2-connector-row')) as HTMLElement[]; + const rowSaying = (text: string) => rows().find((row) => row.textContent?.includes(text)) as HTMLElement; + expect(rowSaying('Launch pod').querySelector('strong')?.textContent).toBe('由 sam 授权给 Launch pod'); + // The audience keeps its space before a Latin name, and loses it when there + // is no audience at all — which is why the empty case is a separate key. + expect(rowSaying('Launch pod').textContent).toContain('Scout 可以使用'); + expect(rowSaying('Ops').textContent).toContain('没有智能体可以使用'); + // The separator key is zh-empty: two sentences that each end in 。 join with + // nothing, where the English pair needs the space. + const aside = document.querySelector('.v2-connectors__aside') as HTMLElement; + expect(within(aside).getByText(/授权。/).textContent).toBe('1小时前由 sam 授权。6天后结束。'); + } 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__/V2FirstRunHero.test.tsx b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx index 3e9faff96..1f3e37a78 100644 --- a/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx +++ b/frontend/src/v2/__tests__/V2FirstRunHero.test.tsx @@ -135,7 +135,7 @@ describe('V2FirstRunHero', () => { renderHero(); await flush(); - expect(screen.getByRole('dialog', { name: 'Bring your agent into the room' })) + expect(screen.getByRole('dialog', { name: 'Bring your agent into your pod' })) .toHaveAttribute('aria-modal', 'true'); expect(screen.getByText('Waiting for your agent to connect…')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Open connection setup/i })).toHaveAttribute('target', '_blank'); @@ -198,11 +198,11 @@ describe('V2FirstRunHero', () => { renderHero(); // The ownership probe must not flash the onboarding card while it resolves. - expect(screen.queryByRole('heading', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Bring your agent into your pod' })).not.toBeInTheDocument(); await flush(); await waitFor(() => { - expect(screen.queryByRole('heading', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Bring your agent into your pod' })).not.toBeInTheDocument(); }); }); @@ -211,7 +211,7 @@ describe('V2FirstRunHero', () => { renderHero(); await flush(); - expect(screen.queryByRole('heading', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Bring your agent into your pod' })).not.toBeInTheDocument(); expect(mockGet).not.toHaveBeenCalledWith('/api/users/me/agent-connection'); }); @@ -226,7 +226,7 @@ describe('V2FirstRunHero', () => { renderHero(); await flush(); - expect(await screen.findByRole('dialog', { name: 'Bring your agent into the room' })) + expect(await screen.findByRole('dialog', { name: 'Bring your agent into your pod' })) .toBeInTheDocument(); }); @@ -237,7 +237,7 @@ describe('V2FirstRunHero', () => { renderHero(); await flush(); - expect(await screen.findByRole('dialog', { name: 'Bring your agent into the room' })) + expect(await screen.findByRole('dialog', { name: 'Bring your agent into your pod' })) .toBeInTheDocument(); }); @@ -248,10 +248,10 @@ describe('V2FirstRunHero', () => { const { unmount } = renderHero(); await flush(); - expect(screen.getByRole('dialog', { name: 'Bring your agent into the room' })).toHaveFocus(); + expect(screen.getByRole('dialog', { name: 'Bring your agent into your pod' })).toHaveFocus(); fireEvent.keyDown(document, { key: 'Escape' }); - expect(screen.queryByRole('dialog', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: 'Bring your agent into your pod' })).not.toBeInTheDocument(); expect(localStorage.getItem(FIRST_RUN_DISMISSED_KEY)).toBeNull(); expect(priorControl).toHaveFocus(); priorControl.remove(); @@ -260,7 +260,7 @@ describe('V2FirstRunHero', () => { unmount(); renderHero(); await flush(); - expect(await screen.findByRole('dialog', { name: 'Bring your agent into the room' })) + expect(await screen.findByRole('dialog', { name: 'Bring your agent into your pod' })) .toBeInTheDocument(); }); @@ -274,16 +274,16 @@ describe('V2FirstRunHero', () => { const { unmount } = renderHero(); await flush(); - const dialog = screen.getByRole('dialog', { name: 'Bring your agent into the room' }); + const dialog = screen.getByRole('dialog', { name: 'Bring your agent into your pod' }); fireEvent.mouseDown(dialog.parentElement as HTMLElement); - expect(screen.queryByRole('dialog', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: 'Bring your agent into your pod' })).not.toBeInTheDocument(); expect(localStorage.getItem(FIRST_RUN_DISMISSED_KEY)).toBeNull(); unmount(); renderHero(); await flush(); - expect(await screen.findByRole('dialog', { name: 'Bring your agent into the room' })) + expect(await screen.findByRole('dialog', { name: 'Bring your agent into your pod' })) .toBeInTheDocument(); }); @@ -314,7 +314,7 @@ describe('V2FirstRunHero', () => { window.dispatchEvent(new Event('commonly:reopen-first-run')); }); - expect(screen.getByRole('dialog', { name: 'Bring your agent into the room' })) + expect(screen.getByRole('dialog', { name: 'Bring your agent into your pod' })) .toBeInTheDocument(); expect(localStorage.getItem(FIRST_RUN_DISMISSED_KEY)).toBeNull(); expect(localStorage.getItem(FIRST_RUN_STARTED_KEY)).toBe('1'); @@ -373,7 +373,7 @@ describe('V2Layout first-run placement', () => { await flush(); expect(screen.getByText('Normal pod view')).toBeInTheDocument(); - const dialog = screen.getByRole('dialog', { name: 'Bring your agent into the room' }); + const dialog = screen.getByRole('dialog', { name: 'Bring your agent into your pod' }); expect(dialog).toBeInTheDocument(); expect(screen.getByTestId('pod-chat')).not.toContainElement(dialog); expect(mockGet).toHaveBeenCalledWith('/api/users/me/agent-connection'); @@ -397,7 +397,7 @@ describe('V2Layout first-run placement', () => { await waitFor(() => { expect(screen.getByText('Quiet pod empty state')).toBeInTheDocument(); }); - expect(screen.queryByRole('dialog', { name: 'Bring your agent into the room' })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: 'Bring your agent into your pod' })).not.toBeInTheDocument(); expect(mockGet).not.toHaveBeenCalledWith('/api/users/me/agent-connection'); }); @@ -418,7 +418,7 @@ describe('V2Layout first-run placement', () => { fireEvent.click(screen.getByRole('button', { name: 'Guide' })); - expect(await screen.findByRole('dialog', { name: 'Bring your agent into the room' })) + expect(await screen.findByRole('dialog', { name: 'Bring your agent into your pod' })) .toBeInTheDocument(); expect(screen.queryByText('Quiet pod empty state')).not.toBeInTheDocument(); expect(localStorage.getItem(FIRST_RUN_DISMISSED_KEY)).toBeNull(); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 06187669d..477683497 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -11,7 +11,7 @@ // Grant, Manage on a row with one act) keeps its word. import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; import { useRelativeNow } from '../hooks/useRelativeNow'; import { localizeRelativeTime, localizeWindow } from '../utils/localizeRelativeTime'; @@ -491,6 +491,7 @@ const V2ConnectorTools: React.FC = ({ pods }) => { const isSelected = selectedId === grant.grantId; const entry = entryFor(grant); const label = toolLabel(grant); + const targetLabel = grant.target.kind === 'pod' ? podName(grant.target.id) : seatLabel(podId, grant.target.id); // Direction A rule 3: `pod · verb age` — the verb from a key, the age from the timestamp. const when = t('tools.grantedAge', { defaultValue: 'granted {{age}}', age: shortAge(grant.createdAt, now, t) }); const kicker = `${podId ? podName(podId) : seatLabel(null, grant.target.id)} · ${when}`; @@ -505,14 +506,19 @@ const V2ConnectorTools: React.FC = ({ pods }) => { // 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) }); + // An empty audience is its own sentence, not the interpolated one with an empty + // subject: zh writes 「没有智能体可以使用」 with no space, which `{{agents}} 可以使用` + // cannot produce (TASK-179). + : (grant.effectiveAudience.length === 0 + ? t('tools.nobodyMayUse', { defaultValue: 'nobody may use it' }) + : t('tools.mayUse', { defaultValue: '{{agents}} may use it', agents: audienceLabels(grant) })); return (