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
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,8 @@
"waiting": "Waiting for {{name}} to check in… this turns green the moment the command connects.",
"verified": "{{name}} is listening — mentions will wake it.",
"stillWaiting": "Still no check-in from {{name}}. Run the command above in Terminal on your computer, and this page will confirm it.",
"stopsOnClose": "This stops when this Terminal window closes. For an agent that keeps running, add this computer under Other ways to connect, then choose On my computer.",
"stopsOnCloseWithComputer": "This stops when this Terminal window closes. For an agent that keeps running, start another agent and choose On my computer.",
"npmPermission": "If npm stops with EACCES (permission denied), don’t add sudo. Reinstall Node with a version manager such as nvm, then run the four lines again.",
"ctaWarning": "{{name}} isn’t listening yet: it won’t answer mentions until the command above is running.",
"needs": "Needs Node 20+ with npm and {{tool}} signed in. Run all four lines in one window: the exports last only there.",
Expand Down Expand Up @@ -1771,9 +1773,11 @@
"slow": "Still waiting — check that commonly daemon run is active on {{machine}}. The agent starts the moment it's back.",
"openPod": "Open the pod"
},
"quickTry": "Quick try: this command runs in Terminal and stops when that window closes. For an agent that should keep running, add this computer once with the daemon, under Other ways to connect below.",
"addComputer": {
"title": "Add a computer",
"after": "Reload to select this computer.",
"recommended": "Recommended for an agent that keeps running. Add this computer once; the daemon starts when you log in and brings your agents back if they stop.",
"after": "Reload this page once it connects. Your computer appears in the list by its name, marked online.",
"copy": "Copy",
"copied": "Copied"
},
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1771,6 +1771,8 @@
"waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。",
"verified": "{{name}} 已在监听 —— @提及 会唤醒它。",
"stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。",
"stopsOnClose": "关闭这个终端窗口后就会停止。如果希望智能体持续运行,请在「其他接入方式」里添加这台电脑,然后选择「在我的电脑上」。",
"stopsOnCloseWithComputer": "关闭这个终端窗口后就会停止。如果希望智能体持续运行,请再添加一个智能体,然后选择「在我的电脑上」。",
"npmPermission": "如果 npm 报 EACCES(权限被拒绝),不要加 sudo。请用版本管理工具(如 nvm)重新安装 Node,然后重新运行这四行。",
"ctaWarning": "{{name}} 尚未监听:上面的命令运行起来之前,它不会回复 @提及。",
"needs": "需要 Node 20+(含 npm)和已登录的 {{tool}}。四行都要在同一个窗口里运行,换了窗口 export 就失效。",
Expand Down Expand Up @@ -1831,9 +1833,11 @@
"slow": "仍在等待——请确认 {{machine}} 上正在运行 commonly daemon run,恢复后会立即启动该智能体。",
"openPod": "打开 pod"
},
"quickTry": "快速试用:这条命令在终端里运行,关闭那个窗口就会停止。如果希望智能体持续运行,请在下方「其他接入方式」里用守护进程把这台电脑添加一次。",
"addComputer": {
"title": "添加电脑",
"after": "刷新页面后即可选择这台电脑。",
"recommended": "推荐用于需要持续运行的智能体。把这台电脑添加一次,守护进程会在你登录时启动,智能体停止后也会自动恢复。",
"after": "连接完成后刷新页面,这台电脑会以名称出现在列表中,并标记为在线。",
"copy": "复制",
"copied": "已复制"
},
Expand Down
170 changes: 170 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOConnectPaths.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// @ts-nocheck
// Keep setup request assertions independent of funnel telemetry.
jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() }));
// TASK-645 item 3 and 4: the two connect paths. The run command is the quick
// try and dies with its Terminal; the daemon is the one that keeps an agent
// running. Each state is pinned by its own copy, so a rewording can't drop it.
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2AgentBYO from '../components/V2AgentBYO';
import { AuthContext } from '../../context/AuthContext';

jest.mock('../components/V2NavRail', () => () => null);
jest.mock('../hooks/useV2PodAttention', () => ({
...jest.requireActual('../hooks/useV2PodAttention'),
useV2PodAttention: () => ({ items: [], count: null, countByPod: {}, refresh: async () => {} }),
}));
jest.mock('axios', () => {
const mock = {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
defaults: { baseURL: '', headers: { common: {} } },
interceptors: {
request: { use: jest.fn(), eject: jest.fn() },
response: { use: jest.fn(), eject: jest.fn() },
},
};
return { __esModule: true, default: mock, ...mock };
});

const axios = jest.requireMock('axios').default;

const authValue = {
currentUser: { _id: 'u1', username: 'sam' },
user: { _id: 'u1', username: 'sam' },
token: 'user-jwt',
loading: false,
error: null,
isAuthenticated: true,
register: jest.fn(),
login: jest.fn(),
logout: jest.fn(),
updateProfile: jest.fn(),
};

const MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
const WINDOWS = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
const realUserAgent = window.navigator.userAgent;
const setUserAgent = (value) => Object.defineProperty(window.navigator, 'userAgent', { value, configurable: true });

const mockPage = ({ machines = [] } = {}) => {
axios.get.mockImplementation((url) => {
if (url.startsWith('/api/hosted/availability')) return Promise.resolve({ data: { configured: false, caps: { agentsPerUser: 1, turnsPerDay: 200 } } });
if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat' }] });
if (url === '/api/machines') return Promise.resolve({ data: { machines, offlineAfterMs: 90000 } });
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: {} });
});
axios.post
.mockResolvedValueOnce({ data: { ok: true } })
.mockResolvedValueOnce({ data: { token: 'cm_agent_test_token' } });
};

const renderPage = () => render(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2AgentBYO />
</MemoryRouter>
</AuthContext.Provider>,
);

const issueRun = async () => {
await waitFor(() => expect(screen.getByText('Workspace (chat)')).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('button', { name: /get my connect command/i })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: /get my connect command/i }));
await screen.findByText(/your connect command for/i);
};

describe('BYO connect paths: run is the quick try, the daemon keeps it running (TASK-645 items 3 and 4)', () => {
afterEach(() => setUserAgent(realUserAgent));
beforeEach(() => jest.clearAllMocks());

test('run path on a Mac: the quick-try line names the daemon, and the command says it stops with its Terminal', async () => {
setUserAgent(MAC);
mockPage({ machines: [] });
renderPage();
expect(await screen.findByTestId('byo-quick-try')).toHaveTextContent(/stops when that window closes/);
expect(screen.getByTestId('byo-quick-try')).toHaveTextContent(/Other ways to connect/);

await issueRun();
const stops = screen.getByTestId('byo-listen-stops-on-close');
expect(stops).toHaveTextContent(/stops when this Terminal window closes/);
expect(stops).toHaveTextContent(/Other ways to connect/);
expect(stops).toHaveTextContent(/On my computer/);
expect(stops).not.toHaveTextContent(/daemon install/);
// The line points at a block that is actually on this page.
expect(screen.getByTestId('byo-result-more-ways')).toContainElement(screen.getByTestId('byo-add-computer'));
});

test('result page with a computer on file: the add-computer block is not offered again', async () => {
setUserAgent(MAC);
mockPage({ machines: [{ machineId: 'm1', name: 'Sams-MacBook', status: 'online', lastSeenAt: new Date().toISOString() }] });
renderPage();
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/machines', expect.anything()));
fireEvent.click(await screen.findByTestId('byo-mode-byo'));
await issueRun();
expect(screen.getByTestId('byo-result-more-ways')).toBeInTheDocument();
expect(screen.queryByTestId('byo-add-computer')).toBeNull();
// The stop-on-close line must not send them to add a computer that is already on file.
const stops = screen.getByTestId('byo-listen-stops-on-close');
expect(stops).not.toHaveTextContent(/add this computer/);
expect(stops).toHaveTextContent(/start another agent and choose On my computer/);
// The choice the line names is on this page: clicking it opens machine mode.
fireEvent.click(screen.getByTestId('byo-result-on-my-computer'));
expect(screen.getByTestId('byo-mode-machine')).toHaveAttribute('aria-pressed', 'true');
expect(screen.queryByText(/your connect command for/i)).toBeNull();
});

test('run path on Windows: no listener command, so no stop-on-close line', async () => {
setUserAgent(WINDOWS);
mockPage({ machines: [] });
renderPage();
await issueRun();
expect(screen.queryByTestId('byo-listen-stops-on-close')).toBeNull();
});

test('daemon path with no computer: marked recommended, and Reload says the computer appears by name, online', async () => {
setUserAgent(MAC);
mockPage({ machines: [] });
renderPage();
const add = await screen.findByTestId('byo-add-computer');
expect(screen.getByTestId('byo-add-computer-recommended')).toHaveTextContent(/Recommended for an agent that keeps running/);
expect(add).toHaveTextContent(/appears in the list by its name, marked online/);
expect(add).toHaveTextContent('commonly daemon register && commonly daemon install');
});

test('on Windows: no quick-try line, and the daemon is not marked recommended (systemd cannot install there)', async () => {
setUserAgent(WINDOWS);
mockPage({ machines: [] });
renderPage();
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/machines', expect.anything()));
expect(screen.queryByTestId('byo-quick-try')).toBeNull();
expect(screen.queryByTestId('byo-add-computer-recommended')).toBeNull();
// Windows gets no daemon block at all: systemd cannot install there, so
// there is nothing to run and nothing to offer.
expect(screen.queryByTestId('byo-add-computer')).toBeNull();
});

test('on Windows, the result page offers no daemon block either', async () => {
setUserAgent(WINDOWS);
mockPage({ machines: [] });
renderPage();
await issueRun();
expect(screen.queryByTestId('byo-add-computer')).toBeNull();
});

test('with a computer already added, the quick-try line and the daemon block are both gone', async () => {
setUserAgent(MAC);
mockPage({ machines: [{ machineId: 'm1', name: 'Sams-MacBook', status: 'online', lastSeenAt: new Date().toISOString() }] });
renderPage();
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/machines', expect.anything()));
// Select Bring your own first: with mode still null the line is absent for
// a different reason, and the test would pass with the machines guard removed.
fireEvent.click(await screen.findByTestId('byo-mode-byo'));
expect(screen.queryByTestId('byo-quick-try')).toBeNull();
expect(screen.queryByTestId('byo-add-computer')).toBeNull();
});
});
3 changes: 2 additions & 1 deletion frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ describe('V2AgentBYO listening verification', () => {
test('the listen snippet carries the CLI install line for first-time machines', async () => {
jest.useFakeTimers();
await issueToken();
expect(screen.getByText(/npm i -g @commonlyai\/cli/)).toBeInTheDocument();
// Scoped to the listen snippet: the result page's Other ways block also shows an npm line.
expect(screen.getByText(/commonly agent run sam-claude/)).toHaveTextContent(/npm i -g @commonlyai\/cli/);
});
});
60 changes: 44 additions & 16 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,26 @@ const V2AgentBYO: React.FC = () => {
}
setIssued(null);
};
// The daemon block lives in Other ways to connect on both the start form and
// the result page, so the stop-on-close line can point to it from the result.
// Windows has no daemon install (systemd), so there is no block to offer.
const addComputerBlock = machines.length === 0 && setupOs !== 'windows' ? (
<div className="v2-byo__add-computer" data-testid="byo-add-computer">
<span className="v2-byo__label">{t('agentByo.addComputer.title')}</span>
<p className="v2-byo__hint" data-testid="byo-add-computer-recommended">{t('agentByo.addComputer.recommended')}</p>
<div className="v2-byo__add-computer-row">
<code>{daemonCommand}</code>
<button
type="button"
className="v2-byo__copy"
onClick={() => copy('daemon-setup', daemonCommand)}
>
{copied === 'daemon-setup' ? t('agentByo.addComputer.copied') : t('agentByo.addComputer.copy')}
</button>
</div>
<p className="v2-byo__hint">{t('agentByo.addComputer.after')}</p>
</div>
) : null;
const runnerPicker = mode === 'byo' && !issued && !onCardStart ? (
<p className="v2-byo__os" data-testid="byo-runner">
{t('agentByo.form.adapterLabel')}{' '}
Expand Down Expand Up @@ -1407,6 +1427,11 @@ const V2AgentBYO: React.FC = () => {
})()}
</button>
)}
{/* The run command is the quick try; it dies with its Terminal. The
daemon is the one that keeps the agent running (TASK-645 item 3). */}
{mode === 'byo' && machines.length === 0 && listenerAvailable && (
<p className="v2-byo__hint" data-testid="byo-quick-try">{t('agentByo.quickTry')}</p>
)}
{/* One command is THE path (QA BYO walk 1): the daemon and the SDK
are real alternatives, but shown as equals beside the button they
read as a choice a stranger has to make before starting. */}
Expand All @@ -1415,22 +1440,7 @@ const V2AgentBYO: React.FC = () => {
if ((event.target as Element).closest('summary, button, a')) event.preventDefault();
}}>
<summary>{t('agentByo.otherWays.title')}</summary>
{machines.length === 0 && (
<div className="v2-byo__add-computer" data-testid="byo-add-computer">
<span className="v2-byo__label">{t('agentByo.addComputer.title')}</span>
<div className="v2-byo__add-computer-row">
<code>{daemonCommand}</code>
<button
type="button"
className="v2-byo__copy"
onClick={() => copy('daemon-setup', daemonCommand)}
>
{copied === 'daemon-setup' ? t('agentByo.addComputer.copied') : t('agentByo.addComputer.copy')}
</button>
</div>
<p className="v2-byo__hint">{t('agentByo.addComputer.after')}</p>
</div>
)}
{addComputerBlock}
<p className="v2-byo__footnote">
{t(listenerAvailable ? 'agentByo.footnote.cliRoute'
: listenAdapter === 'claude' ? 'agentByo.footnote.cliRouteClaudeUnavailable'
Expand Down Expand Up @@ -1618,6 +1628,11 @@ const V2AgentBYO: React.FC = () => {
{t('agentByo.listen.npmPermission')}
</p>
)}
{listenerAvailable && (
<p className="v2-byo__listen-note" data-testid="byo-listen-stops-on-close">
{t(machines.length === 0 ? 'agentByo.listen.stopsOnClose' : 'agentByo.listen.stopsOnCloseWithComputer')}
</p>
)}
{!listenerAvailable ? null : listenState === 'listening' ? (
<p className="v2-byo__live" data-testid="byo-listen-ok">
{t('agentByo.listen.verified', { name: issued.agentName })}
Expand Down Expand Up @@ -1672,6 +1687,19 @@ const V2AgentBYO: React.FC = () => {

<details className="v2-byo__more" data-testid="byo-result-more-ways">
<summary>{t('agentByo.otherWays.title')}</summary>
{addComputerBlock}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{machines.length > 0 && (
// A computer on file: the machine choice is the start form's mode
// picker, which this result page does not show, so offer it here.
<button
type="button"
className="v2-byo__secondary"
data-testid="byo-result-on-my-computer"
onClick={() => { setIssued(null); chooseMode('machine'); }}
>
{t('agentByo.mode.machine')}
</button>
)}
<div className="v2-byo__snippet">
<div className="v2-byo__snippet-head">
<span>{t('agentByo.snippets.runtimeToken')}</span>
Expand Down
Loading