Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 —",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "记忆已导入——",
Expand Down Expand Up @@ -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": {
Expand Down
45 changes: 44 additions & 1 deletion frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {} });
});
Expand All @@ -64,6 +67,7 @@ const issueToken = async (runner?: 'codex', pod = {}) => {
</AuthContext.Provider>,
);
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.
Expand Down Expand Up @@ -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 || '');
Expand Down Expand Up @@ -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();
Expand Down
82 changes: 57 additions & 25 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,26 @@ const V2AgentBYO: React.FC = () => {
</React.Fragment>
))}
</p>
{!listenerAvailable && (
<p className="v2-byo__needs" data-testid="byo-listen-wont-answer">
<strong>{t('agentByo.listen.wontAnswer', { name: issued.agentName })}</strong>
{hosting?.configured && (
<>
{' '}
<button
type="button"
className="v2-byo__secondary"
data-testid="byo-listen-run-here"
disabled={submitting}
onClick={startHostedInstead}
>
{t('agentByo.mode.hosted')}
</button>
</>
)}
{error && <span className="v2-byo__error" role="alert"> {error}</span>}
</p>
)}
<p className="v2-byo__needs" data-testid={listenerAvailable ? 'byo-listen-needs' : 'byo-listen-unavailable'}>
{listenerAvailable
? `${t('agentByo.listen.needs', {
Expand Down Expand Up @@ -1751,31 +1771,43 @@ const V2AgentBYO: React.FC = () => {
</div>
<p data-testid="byo-key-once">{t('agentByo.result.keyOnce')}</p>

{listenAdapter === 'claude' && (
<div className="v2-byo__snippet" data-testid="byo-mcp-claude">
<div className="v2-byo__snippet-head">
{/* Where no listener runs, this is the way in, not an extra. */}
<span>{t(listenerAvailable ? 'agentByo.snippets.claudeCode' : 'agentByo.snippets.claudeCodeMain')}</span>
</div>
<p className="v2-byo__hint">
{t(
setupOs === 'windows' ? 'agentByo.snippets.claudeCodeBodyWindows'
: listenerAvailable ? 'agentByo.snippets.claudeCodeBody' : 'agentByo.snippets.claudeCodeBodyOwn',
{ name: issued.agentName },
)}
</p>
<div className="v2-byo__command" onCopy={copiedIssuedContent}>
<pre className="v2-byo__pre">{setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet}</pre>
<button
type="button"
onClick={() => copy('claude', setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet)}
className="v2-byo__copy"
>
{copied === 'claude' ? t('agentByo.actions.copied') : t('agentByo.actions.copy')}
</button>
{listenAdapter === 'claude' && (() => {
const mcpBlock = (
<div className="v2-byo__snippet" data-testid="byo-mcp-claude">
<div className="v2-byo__snippet-head">
{/* Where no listener runs, this is the way in, not an extra. */}
<span>{t(listenerAvailable ? 'agentByo.snippets.claudeCode' : 'agentByo.snippets.claudeCodeMain')}</span>
</div>
<p className="v2-byo__hint">
{t(
setupOs === 'windows' ? 'agentByo.snippets.claudeCodeBodyWindows'
: listenerAvailable ? 'agentByo.snippets.claudeCodeBody' : 'agentByo.snippets.claudeCodeBodyOwn',
{ name: issued.agentName },
)}
</p>
<div className="v2-byo__command" onCopy={copiedIssuedContent}>
<pre className="v2-byo__pre">{setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet}</pre>
<button
type="button"
onClick={() => copy('claude', setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet)}
className="v2-byo__copy"
>
{copied === 'claude' ? t('agentByo.actions.copied') : t('agentByo.actions.copy')}
</button>
</div>
</div>
</div>
)}
);
// With a listener, the MCP command is the extra: folded away and
// labelled, because a stranger copied it three times, never ran the
// listener, and the agent never answered (walk 2026-10-31). Without a
// listener it is the only way in, so it stays open.
return listenerAvailable ? (
<details className="v2-byo__more" data-testid="byo-mcp-details">
<summary>{t('agentByo.snippets.mcpSummary')}</summary>
{mcpBlock}
</details>
) : mcpBlock;
})()}

<details className="v2-byo__more" data-testid="byo-result-more-ways">
<summary>{t('agentByo.otherWays.title')}</summary>
Expand Down Expand Up @@ -1911,7 +1943,7 @@ const V2AgentBYO: React.FC = () => {
</div>
</aside>
</div>
{issued && showHostedOffer && !hasCopied && hosting?.configured && listenState !== 'listening' && (
{issued && listenerAvailable && showHostedOffer && !hasCopied && hosting?.configured && listenState !== 'listening' && (
<section role="status" className="v2-byo__hosted-offer" aria-label={t('agentByo.hosted.offerAction')}>
<p>{t('agentByo.hosted.offerBody')}</p>
<button type="button" className="v2-byo__submit v2-byo__hosted-offer-action" disabled={submitting} onClick={startHostedInstead}>
Expand Down
Loading