diff --git a/backend/__tests__/unit/services/agentStateService.test.js b/backend/__tests__/unit/services/agentStateService.test.js index f6ea768613..2cc7337385 100644 --- a/backend/__tests__/unit/services/agentStateService.test.js +++ b/backend/__tests__/unit/services/agentStateService.test.js @@ -122,6 +122,20 @@ describe('deriveAgentState', () => { expect(stopped.state).not.toBe('reachable'); }); + // Walk 4 (2026-10-16): the shell hides Scout's "Run an agent here" start + // once the viewer holds a hosted agent, so the row says which agents are hosted. + test('marks a hosted install as hosted, and no other runtime class', () => { + const hosted = deriveAgentState({ + agentName: 'lee-agent', instanceId: 'default', installedBy: 'u1', + config: { runtime: { runtimeType: 'hosted' } }, + }, [], 'u1', NOW); + expect(hosted).toMatchObject({ hosted: true, isOwner: true }); + const others = [{ runtimeType: 'native' }, { runtimeType: 'webhook', host: 'byo' }, { runtimeType: 'moltbot' }, {}]; + for (const runtime of others) { + expect(deriveAgentState({ agentName: 'x', config: { runtime } }, [], 'u1', NOW).hosted).toBe(false); + } + }); + test('a push webhook (webhookUrl set) is reachable regardless of token use', () => { const row = deriveAgentState( byoInstall({ config: { runtime: { runtimeType: 'webhook', host: 'byo', webhookUrl: 'https://x.example/cap' } } }), diff --git a/backend/services/agentStateService.ts b/backend/services/agentStateService.ts index 8ed7021eec..54cec0a2f7 100644 --- a/backend/services/agentStateService.ts +++ b/backend/services/agentStateService.ts @@ -49,6 +49,8 @@ export interface AgentStateRow { state: AgentReachState; lastUsedAt: string | null; isOwner: boolean; + /** A Commonly-hosted agent ("Run it here"); the beta allows one per account. */ + hosted: boolean; fixCommand?: string; } @@ -108,6 +110,8 @@ export function deriveAgentState( state, lastUsedAt: lastUsedAt ? lastUsedAt.toISOString() : null, isOwner, + // The shell hides Scout's "Run it here" start once the viewer holds one. + hosted: runtimeType === 'hosted', ...(isOwner && needsFix ? { fixCommand: `commonly agent run ${agentName}` } : {}), }; } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f21cce772f..1959f6ce80 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1457,6 +1457,14 @@ "after": "Then reload this page — an “On my computer” option appears, and adding agents to that machine never needs a terminal again.", "copy": "Copy", "copied": "Copied" + }, + "compact": { + "title": "Run an agent here", + "lead": "Commonly runs it for you. Nothing to install, and it is live a few seconds after you press the button.", + "joins": "It joins {{pod}} as {{name}}.", + "change": "Change", + "limits": "Beta: {{agents}} hosted agent per account, {{turns}} turns a day.", + "otherWays": "Other ways to connect" } }, "compare": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b3be6abdee..c46cf4928d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1517,6 +1517,14 @@ "after": "然后刷新本页——会出现“在我的电脑上”选项,之后往那台电脑添加智能体再也不需要终端。", "copy": "复制", "copied": "已复制" + }, + "compact": { + "title": "在这里运行智能体", + "lead": "由 Commonly 代为运行,无需安装,点下按钮几秒后即可上线。", + "joins": "它会以 {{name}} 的身份加入 {{pod}}。", + "change": "更改", + "limits": "测试期:每个账号{{agents}}个托管智能体,每天{{turns}}轮。", + "otherWays": "其他接入方式" } }, "agentProfile": { diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index 4ef8a60239..8514d3efe6 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -6,7 +6,7 @@ // token. When it cannot, the page is exactly the BYO page it was. import React from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; @@ -190,21 +190,69 @@ describe('V2AgentBYO — Run it here', () => { ); }; - test('a hosted offer link (?mode=hosted) opens on Run it here, one click from live', async () => { + // Walk 4 (2026-10-16): arriving from a "Run it here" button, a stranger read + // 130 words before reaching it. The link has already chosen hosting, so the + // page is just the act, where it lands and the limits. + test('a hosted offer link (?mode=hosted) opens the compact page, one click from live', async () => { mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); axios.post.mockResolvedValue({ data: { ok: true } }); renderAt('/v2/agents/byo?pod=p1&mode=hosted'); - await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'true')); - expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'false'); - fireEvent.click(screen.getByRole('button', { name: /^run it here$/i })); + const compact = await screen.findByTestId('byo-hosted-compact'); + expect(screen.getByRole('heading', { name: 'Run an agent here' })).toBeInTheDocument(); + await waitFor(() => expect(compact).toHaveTextContent('It joins Workspace as sam-agent.')); + await waitFor(() => expect(compact).toHaveTextContent('Beta: 1 hosted agent per account, 200 turns a day.')); + // None of the full page's choice and setup copy is in the way. + expect(screen.queryByTestId('byo-mode-hosted')).not.toBeInTheDocument(); + expect(screen.queryByTestId('byo-add-computer')).not.toBeInTheDocument(); + const act = screen.getByRole('button', { name: /^run it here$/i }); + await waitFor(() => expect(act).toBeEnabled()); + fireEvent.click(act); await screen.findByTestId('byo-hosted-result'); const installCall = axios.post.mock.calls.find(([url]) => url === '/api/registry/install'); expect(installCall[1]).toMatchObject({ agentName: 'sam-agent', podId: 'p1', config: { runtime: { runtimeType: 'hosted' } } }); window.history.pushState({}, '', '/'); }); + test('"Other ways to connect" opens the full page with Run it here still chosen', async () => { + mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); + renderAt('/v2/agents/byo?pod=p1&mode=hosted'); + + fireEvent.click(await screen.findByTestId('byo-other-ways')); + expect(screen.queryByTestId('byo-hosted-compact')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Bring your own agent' })).toBeInTheDocument(); + expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'true'); + window.history.pushState({}, '', '/'); + }); + + // Walk 4: typing the agent's handle cost 9.6s. "Go to your pod" hands the + // composer "@ " through navigation state. + test('Go to your pod opens the pod with the composer addressed to the new agent', async () => { + mockGet({ configured: true, status: { runtime: { lastPollAt: 1 } } }); + axios.post.mockResolvedValue({ data: { provisioned: true, ready: true } }); + let landed; + const Probe = () => { landed = useLocation(); return null; }; + window.history.pushState({}, '', '/v2/agents/byo?pod=p1&mode=hosted'); + render( + + + + } /> + } /> + + + , + ); + const act = await screen.findByRole('button', { name: /^run it here$/i }); + await waitFor(() => expect(act).toBeEnabled()); + fireEvent.click(act); + fireEvent.click(await screen.findByRole('button', { name: /go to your pod/i })); + expect(landed.pathname).toBe('/v2/pods/p1'); + expect(landed.state).toEqual({ composeDraft: '@sam-agent ' }); + window.history.pushState({}, '', '/'); + }); + test('a hosted offer link offers no act until the instance says it can host', async () => { let answerAvailability; axios.get.mockImplementation((url) => { @@ -216,12 +264,13 @@ describe('V2AgentBYO — Run it here', () => { }); renderAt('/v2/agents/byo?pod=p1&mode=hosted'); - const podPicker = await screen.findByRole('combobox', { name: /install into pod/i }); - await waitFor(() => expect(podPicker).toHaveValue('p1')); - expect(screen.queryByRole('button', { name: /^run it here$/i })).not.toBeInTheDocument(); + // The compact page shows at once, with the act disabled until the + // instance answers that it can host. + expect(await screen.findByTestId('byo-hosted-compact')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^run it here$/i })).toBeDisabled(); await act(async () => { answerAvailability({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 30 } } }); }); - expect(await screen.findByRole('button', { name: /^run it here$/i })).toBeEnabled(); + await waitFor(() => expect(screen.getByRole('button', { name: /^run it here$/i })).toBeEnabled()); window.history.pushState({}, '', '/'); }); diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index 7bc6e2a179..ad59fd9df2 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx @@ -6,7 +6,9 @@ // so nothing else in the pod (a join row, Scout's follow-ups, another member) // can take the offer away before it is seen. import React, { useState } from 'react'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { + fireEvent, render, screen, waitFor, within, +} from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; import V2Thread from '../components/V2Thread'; import { AuthContext } from '../../context/AuthContext'; @@ -262,3 +264,84 @@ describe("V2Thread — a new pod's next steps", () => { expect(startsGroup()).toBeNull(); }); }); + +// Walk 4 (2026-10-16): two first-minute frictions on the hosted path. +describe('Scout starts and the composer after a hosted agent goes live', () => { + const axios = jest.requireMock('axios').default; + const HOSTED = 'Run an agent here, nothing to install'; + const welcomeWithHosted = { + ...welcome, + payload: { + kind: 'starts', + starts: [ + { id: 'run-hosted', label: HOSTED, href: '/v2/agents/byo?pod=pod-1&mode=hosted' }, + ...welcome.payload.starts, + ], + }, + }; + const agentStates = (agents) => { + axios.get.mockImplementation((url) => (typeof url === 'string' && url.endsWith('/agent-states') + ? Promise.resolve({ data: { agents } }) + : Promise.resolve({ data: {} }))); + }; + afterEach(() => { axios.get.mockImplementation(() => Promise.resolve({ data: {} })); }); + + test('"Run an agent here" goes away once the viewer holds a hosted agent; the other starts stay', async () => { + agentStates([{ agentName: 'maya-agent', instanceId: 'default', state: 'reachable', isOwner: true, hosted: true }]); + renderThread([welcomeWithHosted]); + expect(await screen.findByRole('button', { name: CONNECT })).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByRole('button', { name: HOSTED })).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: POD_START })).toBeInTheDocument(); + }); + + test('it stays while the viewer holds none, including when someone else does', async () => { + agentStates([{ agentName: 'lee-agent', instanceId: 'default', state: 'reachable', isOwner: false, hosted: true }]); + renderThread([welcomeWithHosted]); + await waitFor(() => expect(axios.get) + .toHaveBeenCalledWith(expect.stringContaining('/agent-states'), expect.anything())); + expect(await screen.findByRole('button', { name: HOSTED })).toBeInTheDocument(); + }); + + // The real-browser walk caught what the test above could not: the pod + // arrives after the first render, and a hook placed after the `!pod` early + // return crashed the thread ("Rendered more hooks than during the previous + // render"). This renders the loading state first, then the pod. + test('the draft arrives when the pod loads after the first render, without a hooks crash', async () => { + const LateHarness = () => { + const [ready, setReady] = useState(false); + React.useEffect(() => { const id = setTimeout(() => setReady(true), 0); return () => clearTimeout(id); }, []); + const detail = { + pod: ready ? { _id: 'pod-1', name: 'My Workspace', type: 'chat', createdBy: 'u1' } : null, + members: [{ _id: 'u1', username: 'maya', isBot: false }], + messages: ready ? [welcome] : [], + agents: [], + sendMessage: jest.fn(), + loading: !ready, + error: null, + refresh: jest.fn(), + }; + return ; + }; + render( + + + + + , + ); + const composer = await screen.findByPlaceholderText(/message my workspace/i); + await waitFor(() => expect(composer).toHaveValue('@maya-agent ')); + }); + + test('a page that sends someone here can address the composer: the draft arrives, focused, once', async () => { + render( + + + + + , + ); + const composer = await screen.findByPlaceholderText(/message my workspace/i); + await waitFor(() => expect(composer).toHaveValue('@maya-agent ')); + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 71a4e43f44..f64b1e51c9 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -155,6 +155,11 @@ const V2AgentBYO: React.FC = () => { return prefill.name ? 'byo' : null; }); const [hosted, setHosted] = useState<{ agentName: string; podId: string; ready?: boolean } | null>(null); + // Walk 4 (2026-10-16): a stranger who arrived from a "Run it here" button + // read 130 words (31s) before reaching it. They already chose hosting, so + // the page shows only the act, where it lands and the limits; the full page + // is one click away. + const [compactHosted, setCompactHosted] = useState(prefill.hosted); const [hostedState, setHostedState] = useState<'starting' | 'running' | 'slow'>('starting'); // ADR-026 Phase 2: "on my computer" — install + placement request; the // daemon on the chosen machine adopts, provisions, and starts the agent. @@ -178,6 +183,10 @@ const V2AgentBYO: React.FC = () => { const mode = chosenMode === 'hosted' && !hosting?.configured ? (choosingMode ? null : 'byo') : chosenMode ?? (choosingMode ? null : 'byo'); + // Shown from first paint for a hosted link (no flash of the full page while + // availability loads); the act stays disabled until the instance says it can + // host, and an instance that cannot falls back to the full page. + const showCompactHosted = compactHosted && !personaCard && (mode === 'hosted' || !choicesKnown); // One lookup, two readers (TASK-163): the preview note and the placement // payload must name the SAME machine, so it is derived once here rather // than restated at each call site. @@ -660,9 +669,9 @@ const V2AgentBYO: React.FC = () => { return (
@@ -678,7 +687,46 @@ const V2AgentBYO: React.FC = () => {
)} - {!issued && !hosted && !placed && ( + {showCompactHosted && !issued && !hosted && !placed && ( +
+ {/* Each line waits for its own data: no "joins as" or "0 turns" on first paint. */} + {pods.some((p) => p._id === podId) && ( +

+ {t('agentByo.compact.joins', { + name: sanitizeAgentName(name) || DEFAULT_AGENT_NAME, + pod: pods.find((p) => p._id === podId)?.name || '', + })} + {' '} + +

+ )} + {error &&
{error}
} + + {hosting?.configured && ( +

+ {t('agentByo.compact.limits', { agents: hosting.caps.agentsPerUser, turns: hosting.caps.turnsPerDay })} +

+ )} + +
+ )} + {!showCompactHosted && !issued && !hosted && !placed && (
{(hosting?.configured || machines.length > 0) && ( <> @@ -942,7 +990,9 @@ const V2AgentBYO: React.FC = () => {
) : null; + // A page that sends someone here to talk to an agent can hand the composer + // its first words (Bring your own's "Go to your pod" passes "@ "). + // Walk 4 (2026-10-16): typing the 32-character handle cost 9.6s. Read once + // per navigation, then dropped from history so a reload does not refill it. + // Declared above the `!pod` early return: every hook must run on every render. + const composeDraftKeyRef = useRef(null); + useEffect(() => { + const composeDraft = (location.state as { composeDraft?: unknown } | null)?.composeDraft; + if (!pod?._id || typeof composeDraft !== 'string' || !composeDraft.trim()) return; + if (composeDraftKeyRef.current === location.key) return; + composeDraftKeyRef.current = location.key; + const text = composeDraft.slice(0, 200); + setDraft(text); + requestAnimationFrame(() => { + const input = composerInputRef.current; + if (!input) return; + input.focus(); + input.setSelectionRange(text.length, text.length); + }); + navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null }); + // The effect keys on the navigation and the pod arriving, nothing else. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.key, pod?._id]); + if (!pod) { return (
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 5ce3941df3..03170096cf 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -6290,6 +6290,17 @@ body.modern-ui.v2-canvas { color: var(--v2-text-muted); font-size: 14px; } +.v2-root button.v2-byo__link { + /* A text action beside or under a line of copy ("Change", "Other ways to + connect"): the v2-team__link shape, so it reads as a link, not a button. */ + padding: 0; + border: 0; + background: transparent; + color: var(--v2-accent-text); + font: 500 13px/20px var(--v2-font); + cursor: pointer; + align-self: flex-start; +} .v2-root button.v2-byo__secondary { align-self: flex-start; padding: 10px 18px;