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
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1737,6 +1737,7 @@
"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.",
"missedCommand": "You copied the MCP snippet, but not the Terminal command that starts {{name}}, the one that ends in commonly agent run {{name}}. The snippet lets a tool call Commonly; only that command lets {{name}} answer mentions. Copy it above and run it 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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1796,6 +1796,7 @@
"waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。",
"verified": "{{name}} 已在监听 —— @提及 会唤醒它。",
"stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。",
"missedCommand": "你复制了 MCP 片段,但没有复制启动 {{name}} 的终端命令,也就是以 commonly agent run {{name}} 结尾的那条。MCP 片段只能让工具调用 Commonly;只有那条命令才能让 {{name}} 回复 @提及。请复制上面的命令,在你电脑的终端里运行,这里会自动确认。",
"stopsOnClose": "关闭这个终端窗口后就会停止。如果希望智能体持续运行,请在「其他接入方式」里添加这台电脑,然后选择「在我的电脑上」。",
"stopsOnCloseWithComputer": "关闭这个终端窗口后就会停止。如果希望智能体持续运行,请再添加一个智能体,然后选择「在我的电脑上」。",
"npmPermission": "如果 npm 报 EACCES(权限被拒绝),不要加 sudo。请用版本管理工具(如 nvm)重新安装 Node,然后重新运行这四行。",
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,62 @@ describe('V2AgentBYO listening verification', () => {
).length).toBe(readsAfterFlip);
});

// TASK-721: a stranger copied the MCP snippet three times and never the
// Terminal command (walk 2026-10-31). After three minutes the page names it.
describe('the missed Terminal command', () => {
// The missed-command line quotes the command too, so pick the snippet itself.
const snippetBlock = (matcher) => screen.getAllByText(matcher)
.find((el) => el.classList.contains('v2-byo__pre')).closest('.v2-byo__command');
const copyBlock = (matcher) => fireEvent.copy(snippetBlock(matcher));
const waitingText = () => screen.getByTestId('byo-listen-waiting').textContent;

test('MCP snippet copied, listener command not, after three minutes: names the Terminal command', async () => {
jest.useFakeTimers();
await issueToken();
copyBlock(/claude mcp add/);

await act(async () => { jest.advanceTimersByTime(2 * 60 * 1000); });
expect(waitingText()).not.toMatch(/not the Terminal command/);

await act(async () => { jest.advanceTimersByTime(61 * 1000); });
expect(waitingText()).toMatch(/copied the MCP snippet, but not the Terminal command that starts sam-claude, the one that ends in commonly agent run sam-claude/);
});

test('the Copy buttons count the same way as a manual copy', async () => {
jest.useFakeTimers();
Object.defineProperty(navigator, 'clipboard', {
configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) },
});
await issueToken();
const copyButtonFor = (matcher) => snippetBlock(matcher).querySelector('button');

await act(async () => { fireEvent.click(copyButtonFor(/claude mcp add/)); });
await act(async () => { jest.advanceTimersByTime(3 * 60 * 1000 + 1000); });
expect(waitingText()).toMatch(/not the Terminal command that starts sam-claude/);

await act(async () => { fireEvent.click(copyButtonFor(/commonly agent run sam-claude/)); });
expect(waitingText()).not.toMatch(/not the Terminal command/);
});

test('the listener command copied too: keeps the plain waiting line', async () => {
jest.useFakeTimers();
await issueToken();
copyBlock(/claude mcp add/);
copyBlock(/commonly agent run sam-claude/);

await act(async () => { jest.advanceTimersByTime(4 * 60 * 1000); });
expect(waitingText()).not.toMatch(/not the Terminal command/);
});

test('nothing copied at all: keeps the plain waiting line', async () => {
jest.useFakeTimers();
await issueToken();

await act(async () => { jest.advanceTimersByTime(4 * 60 * 1000); });
expect(waitingText()).not.toMatch(/not the Terminal command/);
});
});

test('a transient poll failure keeps waiting rather than erroring', async () => {
jest.useFakeTimers();
await issueToken();
Expand Down
40 changes: 34 additions & 6 deletions frontend/src/v2/components/V2AgentBYO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ const podIsConfirmedPrivate = (pod?: V2Pod): boolean => (
&& !pod.communityListed
&& pod.joinPolicy === 'invite-only'
);
// How long a person may wait, MCP snippet copied and listener command not, before
// the page names the command they missed.
const MISSED_LISTENER_AFTER_MS = 3 * 60 * 1000;

const listenerRunsOn = (os: SetupOs, adapter: 'claude' | 'codex', pod?: V2Pod): boolean => (
os === 'mac' || (os === 'linux' && adapter === 'claude' && podIsConfirmedPrivate(pod))
);
Expand Down Expand Up @@ -260,6 +264,12 @@ const V2AgentBYO: React.FC = () => {
// finish the page, mention the agent, and get silence with no way to know
// which step they missed. 'waiting' → 'listening' | 'timeout'.
const [listenState, setListenState] = useState<'waiting' | 'listening' | 'timeout'>('waiting');
// Which command the person copied for THIS issue, and whether they have been
// waiting long enough to say so. A stranger copied the MCP snippet three
// times and never the Terminal command (walk 2026-10-31, TASK-721): the page
// then names the one they missed instead of waiting in silence.
const [copiedKinds, setCopiedKinds] = useState({ mcp: false, listen: false });
const [missedWindowOver, setMissedWindowOver] = useState(false);
// ADR-023 W2 "Run it here" — the zero-terminal path. Offered only when the
// instance reports a configured hosted runtime, and the default there: the
// stranger this page exists for has no runtime to bring. The kernel mints
Expand Down Expand Up @@ -928,6 +938,16 @@ const V2AgentBYO: React.FC = () => {
}
};

// A new issue starts a new copy record and a new clock. Three minutes is long
// enough to run a pasted command; the server nudge in the room waits longer.
useEffect(() => {
setCopiedKinds({ mcp: false, listen: false });
setMissedWindowOver(false);
if (!issued) return undefined;
const timer = setTimeout(() => setMissedWindowOver(true), MISSED_LISTENER_AFTER_MS);
return () => clearTimeout(timer);
}, [issued?.issuedAt]);

// Poll the kernel for THIS agent's first listener check-in after
// issuance (an events poll or socket connect, not a tool call).
// `/api/users/me/agent-connection` reports the caller's latest
Expand Down Expand Up @@ -1030,6 +1050,10 @@ const V2AgentBYO: React.FC = () => {
};
}, [api, issued, offerSessionKey, hasCopied, hosting?.configured, listenState]);

const markCopiedKind = (kind: 'mcp' | 'listen') => {
setCopiedKinds((prev) => (prev[kind] ? prev : { ...prev, [kind]: true }));
};

const copiedIssuedContent = () => {
rememberHostedOffer();
setHasCopied(true);
Expand All @@ -1040,6 +1064,8 @@ const V2AgentBYO: React.FC = () => {
try {
await navigator.clipboard.writeText(value);
copiedIssuedContent();
if (key === 'claude' || key === 'cursor') markCopiedKind('mcp');
else if (key === 'listen') markCopiedKind('listen');
setCopied(key);
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
if (key === 'claude' || key === 'cursor') byoStep('mcp-command-copied');
Expand Down Expand Up @@ -1682,7 +1708,7 @@ const V2AgentBYO: React.FC = () => {
</button>
</p>
{listenerAvailable && (
<div className="v2-byo__command" onCopy={copiedIssuedContent}>
<div className="v2-byo__command" onCopy={() => { copiedIssuedContent(); markCopiedKind('listen'); }}>
<pre className="v2-byo__pre">{listenSnippet}</pre>
<button
type="button"
Expand All @@ -1709,9 +1735,11 @@ const V2AgentBYO: React.FC = () => {
</p>
) : (
<p className="v2-byo__listen-note" data-testid="byo-listen-waiting">
{listenState === 'timeout'
? t('agentByo.listen.stillWaiting', { name: issued.agentName })
: t('agentByo.listen.waiting', { name: issued.agentName })}
{missedWindowOver && copiedKinds.mcp && !copiedKinds.listen
? t('agentByo.listen.missedCommand', { name: issued.agentName })
: listenState === 'timeout'
? t('agentByo.listen.stillWaiting', { name: issued.agentName })
: t('agentByo.listen.waiting', { name: issued.agentName })}
</p>
)}
{listenerAvailable && (
Expand Down Expand Up @@ -1786,7 +1814,7 @@ const V2AgentBYO: React.FC = () => {
{ name: issued.agentName },
)}
</p>
<div className="v2-byo__command" onCopy={copiedIssuedContent}>
<div className="v2-byo__command" onCopy={() => { copiedIssuedContent(); markCopiedKind('mcp'); }}>
<pre className="v2-byo__pre">{setupOs === 'windows' ? claudeSnippetWindows : claudeSnippet}</pre>
<button
type="button"
Expand Down Expand Up @@ -1842,7 +1870,7 @@ const V2AgentBYO: React.FC = () => {
</div>
<a className="v2-byo__cursor-link" href={cursorInstallHref}>{t('agentByo.snippets.cursorInstall')}</a>
<p className="v2-byo__hint">{t('agentByo.snippets.cursorInstallHint')}</p>
<div className="v2-byo__command" onCopy={copiedIssuedContent}>
<div className="v2-byo__command" onCopy={() => { copiedIssuedContent(); markCopiedKind('mcp'); }}>
<pre className="v2-byo__pre">{cursorSnippet}</pre>
<button type="button" onClick={() => copy('cursor', cursorSnippet)} className="v2-byo__copy">
{copied === 'cursor' ? t('agentByo.actions.copied') : t('agentByo.actions.copy')}
Expand Down
Loading