diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9f4722335c..55677be7b9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 86754ad1e5..e958dbb294 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -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,然后重新运行这四行。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx index b27a50cab7..4ba0a06fd1 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx @@ -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(); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index bab529d6f6..7b694dc4a6 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -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)) ); @@ -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 @@ -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 @@ -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); @@ -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'); @@ -1682,7 +1708,7 @@ const V2AgentBYO: React.FC = () => {
{listenerAvailable && ( -{listenSnippet}