From f9a8e390420e879816ecbafd745ef4e013b65e48 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:14:06 -0700 Subject: [PATCH] feat(first-session): the hosted path drops 100 words of reading, addresses the composer, and stops offering a slot that's taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA walk 4 (person-paced, production ccd40467): page visible to a live agent took 1:27, and the first real answer ~2:31. The product's own waits were 7.7s; reading (64s) and typing (60s) were the rest. Three levers: 1. Bring your own via a "Run it here" link (?mode=hosted) read 130 words (31s) before its button. That person already chose hosting, so the page is now: "Run an agent here", a 20-word lead, one line saying where it lands ("It joins My Workspace as ." + Change), the button, and the limits line. "Other ways to connect" opens the full page with hosting still chosen. It's shown from first paint with the button disabled until availability says the instance can host; a non-hosting instance gets the full page. Each line waits for its data (no "joins as" or "0 turns" on first paint). Measured in Chromium against the production API: ~33 words before the button (from 130), button at y=278 at 1440x900 and y=377 at 390x844, no horizontal scroll. 2. Typing the agent's 32-character handle cost 9.6s. "Go to your pod" now passes "@ " in navigation state, and the thread fills and focuses the composer once per navigation, then drops it from history so a reload doesn't refill it. The effect sits above V2Thread's `!pod` early return. The first version sat below it and the browser check crashed it ("Rendered more hooks than during the previous render"); jsdom couldn't see that, so there's now a load-order test. 3. Scout's "Run an agent here" start (and Scout's hosted-offer button, the same id) stayed under the welcome after the agent was live. The agent-states roster now says which agents are hosted, and the thread hides the run-hosted start once the viewer owns one in the pod. The other starts stay. Copy in en + zh-CN (zh-cn-ui-localization glossary: 智能体, 托管, 你; the limits line matches the existing zh format). One text-link style (v2-byo__link), the same declarations as v2-team__link: no new tokens. Tests: the compact page (title, joins line, limits, no full-page copy, one click to live), Other ways to connect, the disabled-until-available act, Go to your pod's draft, the draft applied once, the draft surviving a late pod load, the start hidden only when the viewer holds a hosted agent, and the hosted roster flag. Mutations: no compact view, no draft on Go to your pod, start not hidden, no prefill, roster never hosted, and the effect below the early return each fail an arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../unit/services/agentStateService.test.js | 14 +++ backend/services/agentStateService.ts | 4 + frontend/src/i18n/locales/en.json | 8 ++ frontend/src/i18n/locales/zh-CN.json | 8 ++ .../v2/__tests__/V2AgentBYOHosted.test.tsx | 67 +++++++++++++-- .../src/v2/__tests__/V2ThreadStarts.test.tsx | 85 ++++++++++++++++++- frontend/src/v2/components/V2AgentBYO.tsx | 60 +++++++++++-- frontend/src/v2/components/V2Thread.tsx | 41 +++++++-- frontend/src/v2/v2.css | 11 +++ 9 files changed, 278 insertions(+), 20 deletions(-) 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;