diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0bc407ef8b..9f4722335c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1700,7 +1700,8 @@ "claudeCodeBody": "Gives your Claude Code sessions Commonly’s tools. Answering @mentions needs the command above.", "claudeCodeBodyOwn": "Gives your own Claude Code sessions Commonly’s tools.", "claudeCodeBodyWindows": "Paste both lines in PowerShell. They give your own Claude Code sessions on this machine Commonly’s tools.", - "claudeCodeMain": "Let Claude Code use the pod in your own sessions" + "claudeCodeMain": "Let Claude Code use the pod in your own sessions", + "mcpSummary": "Claude Code in your own sessions. This does not answer @mentions." }, "memory": { "doneLead": "Memory imported —", @@ -1751,6 +1752,7 @@ "unavailableClaudeLinux": "On Linux, Claude Code answers @mentions only in an invite-only pod, and this one isn’t. Its owner can switch it to Invite-only under “Who can join” in pod settings. Or connect Claude Code from your own sessions with the command below, or choose “Run it here” for a hosted agent.", "unavailableClaude": "Claude Code can’t answer @mentions from {{os}} yet: Commonly runs it in a sandbox, and on {{os}} that sandbox can’t run it. Connect Claude Code from your own sessions with the command below, or choose “Run it here” for a hosted agent.", "unavailableCodex": "The Codex CLI can’t answer @mentions from {{os}} yet. Choose “Run it here” for a hosted agent, or run the command on a Mac. Linux is not tested yet.", + "wontAnswer": "{{name}} will not answer @mentions from here.", "useDifferentRunner": "Use a different adapter", "repo": { "label": "Work in my repo", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 3fdb47e2c4..86754ad1e5 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1759,7 +1759,8 @@ "claudeCodeBody": "为你的 Claude Code 会话加上 Commonly 的工具。回复 @提及需要上面的命令。", "claudeCodeBodyOwn": "为你自己的 Claude Code 会话加上 Commonly 的工具。", "claudeCodeBodyWindows": "两行都粘贴到 PowerShell 里运行。这样你在这台电脑上的 Claude Code 会话就能用上 Commonly 的工具。", - "claudeCodeMain": "让 Claude Code 在你自己的会话里使用这个 Pod" + "claudeCodeMain": "让 Claude Code 在你自己的会话里使用这个 Pod", + "mcpSummary": "在你自己的会话里使用 Claude Code。这不会回复 @提及。" }, "memory": { "doneLead": "记忆已导入——", @@ -1809,6 +1810,7 @@ "unavailableClaudeLinux": "在 Linux 上,Claude Code 只在仅限邀请的 Pod 里回复 @提及,而这个 Pod 不是。Pod 的所有者可以在 Pod 设置的「谁可以加入」里改成「仅限邀请」。你也可以用下面的命令把 Commonly 的工具接进自己的 Claude Code 会话,或者选择「在这里运行」,改用托管智能体。", "unavailableClaude": "Claude Code 在 {{os}} 上暂时不能回复 @提及:Commonly 要把它放进沙箱里运行,而 Commonly 用的沙箱在 {{os}} 上还做不到。你可以用下面的命令把 Commonly 的工具接进自己的 Claude Code 会话,或者选择“在这里运行”,改用托管智能体。", "unavailableCodex": "Codex CLI 在 {{os}} 上暂时不能回复 @提及。你可以选择“在这里运行”,改用托管智能体;也可以换到 Mac 机器上运行这条命令(Linux 尚未测试)。", + "wontAnswer": "{{name}} 在这里不会回复 @提及。", "toolCodex": "Codex CLI", "useDifferentRunner": "换一种适配器", "repo": { diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index dae9d81d8d..9c60df3af9 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -7,7 +7,7 @@ jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // CLI skipped the install and failed on the last line with "command not found" // (the #887 class the snippet was moved here to prevent). import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; @@ -47,9 +47,12 @@ const authValue = { updateProfile: jest.fn(), }; +let hostedConfigured = false; + const issueToken = async (runner?: 'codex', pod = {}) => { axios.get.mockImplementation((url) => { if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat', ...pod }] }); + if (url.startsWith('/api/hosted/availability')) return Promise.resolve({ data: { configured: hostedConfigured, caps: { agentsPerUser: 3, turnsPerDay: 20 } } }); if (url.startsWith('/api/registry/agents/next-free-name?')) return Promise.resolve({ data: { name: new URLSearchParams(url.split('?')[1]).get('base') } }); return Promise.resolve({ data: {} }); }); @@ -64,6 +67,7 @@ const issueToken = async (runner?: 'codex', pod = {}) => { , ); await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument()); + if (hostedConfigured) fireEvent.click(await screen.findByTestId('byo-mode-byo')); await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled()); if (runner) fireEvent.click(screen.getByTestId(`byo-runner-${runner}`)); // The runner sets the default name, which re-checks it, so wait for the button again. @@ -129,6 +133,16 @@ describe('BYO listener snippet', () => { // "Claude Code" was the MCP line, which does not make the agent answer. The // command that does is THE box: first, and the only one not marked optional // or folded away. + test('with a listener the MCP block is folded under it and says it does not answer @mentions', async () => { + await issueToken(); + const details = screen.getByTestId('byo-mcp-details'); + expect(details.tagName).toBe('DETAILS'); + expect(details).not.toHaveAttribute('open'); + expect(details.querySelector('summary')).toHaveTextContent(/does not answer @mentions/); + expect(details).toContainElement(screen.getByTestId('byo-mcp-claude')); + expect(screen.queryByTestId('byo-listen-wont-answer')).toBeNull(); + }); + test('the connect command is the first box; the MCP line is optional; the bare key is folded away', async () => { await issueToken(); const pres = Array.from(document.querySelectorAll('.v2-byo__pre')).map((el) => el.textContent || ''); @@ -421,6 +435,35 @@ describe('BYO listener snippet', () => { expect(screen.queryByTestId('byo-mcp-claude')).toBeNull(); }); + test('no listener: the first line says it will not answer @mentions, MCP stays open, no Run it here without hosting', async () => { + setUserAgent(WINDOWS); + await issueToken(); + const wont = screen.getByTestId('byo-listen-wont-answer'); + expect(wont).toHaveTextContent(/will not answer @mentions from here/); + expect(wont.compareDocumentPosition(screen.getByTestId('byo-listen-unavailable')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.queryByTestId('byo-listen-run-here')).toBeNull(); + expect(screen.queryByTestId('byo-mcp-details')).toBeNull(); + expect(screen.getByTestId('byo-mcp-claude')).toBeInTheDocument(); + }); + + test('no listener with hosting on: the first line carries a Run it here button', async () => { + setUserAgent(WINDOWS); + hostedConfigured = true; + try { + await issueToken(); + expect(within(screen.getByTestId('byo-listen-wont-answer')).getByRole('button', { name: 'Run it here' })).toBeInTheDocument(); + // The hosted offer section would repeat the same action: one button only. + expect(screen.getAllByRole('button', { name: /Run it here/ })).toHaveLength(1); + // A failed start shows its error beside the button, even after a copy. + axios.get.mockImplementation((url) => (url.startsWith('/api/registry/agents/next-free-name') + ? Promise.reject(new Error('boom')) : Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 3, turnsPerDay: 20 } } }))); + fireEvent.click(screen.getByTestId('byo-listen-run-here')); + await waitFor(() => expect(within(screen.getByTestId('byo-listen-wont-answer')).getByRole('alert')).toBeInTheDocument()); + } finally { + hostedConfigured = false; + } + }); + test('the switch overrides the browser: Windows to Mac brings the listener back', async () => { setUserAgent(WINDOWS); await issueToken(); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index a2a7ea5fde..f719ad13ad 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -1641,6 +1641,26 @@ const V2AgentBYO: React.FC = () => { ))}
+ {!listenerAvailable && ( ++ {t('agentByo.listen.wontAnswer', { name: issued.agentName })} + {hosting?.configured && ( + <> + {' '} + + > + )} + {error && {error}} +
+ )}{listenerAvailable ? `${t('agentByo.listen.needs', { @@ -1751,31 +1771,43 @@ const V2AgentBYO: React.FC = () => {
{t('agentByo.result.keyOnce')}
- {listenAdapter === 'claude' && ( -- {t( - setupOs === 'windows' ? 'agentByo.snippets.claudeCodeBodyWindows' - : listenerAvailable ? 'agentByo.snippets.claudeCodeBody' : 'agentByo.snippets.claudeCodeBodyOwn', - { name: issued.agentName }, - )} -
-{setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet}
-
+ {listenAdapter === 'claude' && (() => {
+ const mcpBlock = (
+ + {t( + setupOs === 'windows' ? 'agentByo.snippets.claudeCodeBodyWindows' + : listenerAvailable ? 'agentByo.snippets.claudeCodeBody' : 'agentByo.snippets.claudeCodeBodyOwn', + { name: issued.agentName }, + )} +
+{setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet}
+
+ {t('agentByo.hosted.offerBody')}