diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/README.md b/docs/design/evidence/byo-hosted-offer-2026-10-24/README.md new file mode 100644 index 0000000000..45624455af --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/README.md @@ -0,0 +1,41 @@ +# Stalled BYO hosted bar + +TASK-482/483, 2026-10-24. Real isolated Chromium with synthetic API responses +and the literal `fixture-only-key`. No real user session, credential, or +production write is used. + +Lily's 16:28Z ruling supersedes the earlier in-flow placement: the offer is a +sticky bottom bar with an ink action, a top border and no shadow. Its 96px desktop +or 128px phone height is reserved from the first render; a negative top margin +means inserting the bar adds no flow height. The initial content pane padding +includes that same height, so full scroll reveals the final control above it. + +Each language (`en`, `zh-CN`) and viewport (`1440×1000`, `390×844`) has: + +- `offer-*.png`: the bar visible immediately at the initial scroll position. +- `full-scroll-*.png`: the last existing control remains above the bar. +- `copied-*.png`: command copy dismissed the offer. +- `reload-*.png`: same-tab reload, reissue/render the same agent's command, + advance past 30 seconds; the offer remains absent. +- `result-*.json`: bar/button dimensions, scroll/client widths, fixed event + names and browser errors. Phone captures advance the browser clock; desktop + captures use top-edge exit intent. + +`command-tops-*.json` and `sticky-clearance-*.json` come from +`e2e/byo-offer-layout.spec.ts`. At 1440×1000 and 390×844, all four commands, +the memory textarea, final CTA row and both final buttons retain identical tops +when the bar appears. Memory and the final buttons are fully on screen while +the timer fires. The bar stays in view at the top and bottom of the pane; at +full scroll the last button clears its top edge. Reserved height equals measured +bar height both before key issuance and after the bar appears. + +Run against the normal frontend dev server from the repository root: +`E2E_BASE_URL=http://localhost:3000 npx playwright test e2e/byo-offer-layout.spec.ts`. +All API responses and authentication are fixtures; no backend is required. + +Chinese action: `改为在这里运行`. +Chinese supporting text: `无需终端。在这个 Pod 中启动一个新的托管智能体。` + +### Provision error at 390 px + +`provision-error-en-390.png` shows the intercepted 503 response with a two-line error. The sticky bar uses a minimum height and grows for error text; it has no nested scroller. The Chromium spec reproduced clipping before the CSS fix and passes after it, including the existing stationary controls, viewport and full-scroll clearance assertions. diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/command-tops-1440.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/command-tops-1440.json new file mode 100644 index 0000000000..a5343c8a8e --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/command-tops-1440.json @@ -0,0 +1,27 @@ +{ + "width": 1440, + "before": [ + -595, + -258, + -61, + 77 + ], + "after": [ + -595, + -258, + -61, + 77 + ], + "controlsBefore": [ + 512, + 703, + 703, + 786 + ], + "controlsAfter": [ + 512, + 703, + 703, + 786 + ] +} diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/command-tops-390.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/command-tops-390.json new file mode 100644 index 0000000000..7383ad54a3 --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/command-tops-390.json @@ -0,0 +1,27 @@ +{ + "width": 390, + "before": [ + -1168, + -591, + -314, + -136 + ], + "after": [ + -1168, + -591, + -314, + -136 + ], + "controlsBefore": [ + 399, + 621, + 621, + 724 + ], + "controlsAfter": [ + 399, + 621, + 621, + 724 + ] +} diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-en-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-en-1440.png new file mode 100644 index 0000000000..f3b9eb5e73 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-en-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-en-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-en-390.png new file mode 100644 index 0000000000..58270f8660 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-en-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-zh-CN-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-zh-CN-1440.png new file mode 100644 index 0000000000..7437994b13 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-zh-CN-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-zh-CN-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-zh-CN-390.png new file mode 100644 index 0000000000..27cd52b712 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/copied-zh-CN-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-en-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-en-1440.png new file mode 100644 index 0000000000..117ba5fbb0 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-en-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-en-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-en-390.png new file mode 100644 index 0000000000..c1d816b59b Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-en-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-zh-CN-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-zh-CN-1440.png new file mode 100644 index 0000000000..fdc0f271e7 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-zh-CN-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-zh-CN-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-zh-CN-390.png new file mode 100644 index 0000000000..2698db4027 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/full-scroll-zh-CN-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-en-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-en-1440.png new file mode 100644 index 0000000000..4d019eb241 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-en-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-en-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-en-390.png new file mode 100644 index 0000000000..3e60d86f36 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-en-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-zh-CN-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-zh-CN-1440.png new file mode 100644 index 0000000000..a864cb2db1 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-zh-CN-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-zh-CN-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-zh-CN-390.png new file mode 100644 index 0000000000..611b83e59c Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/offer-zh-CN-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/provision-error-en-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/provision-error-en-390.png new file mode 100644 index 0000000000..bfe86afac8 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/provision-error-en-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-en-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-en-1440.png new file mode 100644 index 0000000000..c391ac32d0 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-en-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-en-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-en-390.png new file mode 100644 index 0000000000..58e2f29cb7 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-en-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-zh-CN-1440.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-zh-CN-1440.png new file mode 100644 index 0000000000..15f3388e07 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-zh-CN-1440.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-zh-CN-390.png b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-zh-CN-390.png new file mode 100644 index 0000000000..de34fbe332 Binary files /dev/null and b/docs/design/evidence/byo-hosted-offer-2026-10-24/reload-zh-CN-390.png differ diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/result-en-1440.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-en-1440.json new file mode 100644 index 0000000000..87d006d5d8 --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-en-1440.json @@ -0,0 +1,36 @@ +{ + "lang": "en", + "width": 1440, + "metrics": { + "buttonHeight": 40, + "buttonWidth": 160.09375, + "rowWidth": 1240, + "barHeight": 96, + "position": "sticky", + "bg": "rgb(16, 24, 40)", + "afterCommand": true, + "scrollWidth": 1440, + "clientWidth": 1440 + }, + "copySuppressed": true, + "reloadSuppressed": true, + "phoneTimerOnly": false, + "events": [ + { + "step": "page-viewed" + }, + { + "step": "seat-created" + }, + { + "step": "cli-command-copied" + }, + { + "step": "page-viewed" + }, + { + "step": "seat-created" + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/result-en-390.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-en-390.json new file mode 100644 index 0000000000..f7e64f2ca3 --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-en-390.json @@ -0,0 +1,36 @@ +{ + "lang": "en", + "width": 390, + "metrics": { + "buttonHeight": 44, + "buttonWidth": 290, + "rowWidth": 322, + "barHeight": 128, + "position": "sticky", + "bg": "rgb(16, 24, 40)", + "afterCommand": true, + "scrollWidth": 390, + "clientWidth": 390 + }, + "copySuppressed": true, + "reloadSuppressed": true, + "phoneTimerOnly": true, + "events": [ + { + "step": "page-viewed" + }, + { + "step": "seat-created" + }, + { + "step": "cli-command-copied" + }, + { + "step": "page-viewed" + }, + { + "step": "seat-created" + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/result-zh-CN-1440.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-zh-CN-1440.json new file mode 100644 index 0000000000..a2aae8101b --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-zh-CN-1440.json @@ -0,0 +1,36 @@ +{ + "lang": "zh-CN", + "width": 1440, + "metrics": { + "buttonHeight": 40, + "buttonWidth": 133.96875, + "rowWidth": 1240, + "barHeight": 96, + "position": "sticky", + "bg": "rgb(16, 24, 40)", + "afterCommand": true, + "scrollWidth": 1440, + "clientWidth": 1440 + }, + "copySuppressed": true, + "reloadSuppressed": true, + "phoneTimerOnly": false, + "events": [ + { + "step": "page-viewed" + }, + { + "step": "seat-created" + }, + { + "step": "cli-command-copied" + }, + { + "step": "page-viewed" + }, + { + "step": "seat-created" + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/result-zh-CN-390.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-zh-CN-390.json new file mode 100644 index 0000000000..1ee51e661a --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/result-zh-CN-390.json @@ -0,0 +1,36 @@ +{ + "lang": "zh-CN", + "width": 390, + "metrics": { + "buttonHeight": 44, + "buttonWidth": 290, + "rowWidth": 322, + "barHeight": 128, + "position": "sticky", + "bg": "rgb(16, 24, 40)", + "afterCommand": true, + "scrollWidth": 390, + "clientWidth": 390 + }, + "copySuppressed": true, + "reloadSuppressed": true, + "phoneTimerOnly": true, + "events": [ + { + "step": "page-viewed" + }, + { + "step": "seat-created" + }, + { + "step": "cli-command-copied" + }, + { + "step": "page-viewed" + }, + { + "step": "seat-created" + } + ], + "errors": [] +} \ No newline at end of file diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/sticky-clearance-1440.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/sticky-clearance-1440.json new file mode 100644 index 0000000000..c2ce2f73b4 --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/sticky-clearance-1440.json @@ -0,0 +1,7 @@ +{ + "width": 1440, + "barHeight": 96, + "reservedHeight": 96, + "barTop": 889, + "lastControlBottom": 825 +} diff --git a/docs/design/evidence/byo-hosted-offer-2026-10-24/sticky-clearance-390.json b/docs/design/evidence/byo-hosted-offer-2026-10-24/sticky-clearance-390.json new file mode 100644 index 0000000000..9e1adbabaa --- /dev/null +++ b/docs/design/evidence/byo-hosted-offer-2026-10-24/sticky-clearance-390.json @@ -0,0 +1,7 @@ +{ + "width": 390, + "barHeight": 128, + "reservedHeight": 128, + "barTop": 716, + "lastControlBottom": 652 +} diff --git a/docs/development/byo-funnel-telemetry.md b/docs/development/byo-funnel-telemetry.md new file mode 100644 index 0000000000..1fd545fe19 --- /dev/null +++ b/docs/development/byo-funnel-telemetry.md @@ -0,0 +1,39 @@ +# BYO funnel events + +The authenticated BYO page posts fixed event names to `POST /api/stats/byo-step`. +The endpoint stores `userId`, `step`, and server time in `byo_telemetry`. +Commands, keys, names, and clipboard contents are never sent. + +As of TASK-483 (2026-10-24), the browser sends: + +| Event | Observed state | +| --- | --- | +| `page-viewed` | An authenticated user mounted the BYO page, including its hosted entry variant. Once per mount, including React StrictMode effect replay. | +| `seat-created` | A runtime key was issued and its command rendered. Once per issued command. This is the command-exposure denominator. | +| `mcp-command-copied` | The Claude or Cursor copy button successfully wrote the command. | +| `cli-command-copied` | The listener copy button successfully wrote the command. | +| `token-copied` | The token copy button successfully wrote the key. | +| `listen-confirmed` | The connection poll observed this agent using its key after issuance (with existing clock-skew allowance). | +| `listen-timeout` | The page's connection polling window expired. | + +Before TASK-483, the client never emitted `page-viewed` or `seat-created`. +Historical counts of those events cannot measure page or command exposure. +A selected-text copy from a command or key block dismisses the hosted offer but does not claim that a complete +command was copied in telemetry. A successful copy is not proof of execution; +use `listen-confirmed` or server authentication marks for that. + +TASK-482 offers a new hosted agent in the same pod after 30 seconds with an +issued command and no copy, or desktop (over 760px) exit through the top edge. At 390px only the timer triggers it. The offer is a sticky bottom bar with an ink action, a top border and a status announcement. Its height is reserved from the first render, and its negative margin adds no flow height when it appears. It is shown once per owner/agent/pod panel per browser-tab session; a prior display or copy suppresses it after reload. Only a non-secret seen marker is stored in sessionStorage. Unconfigured +hosting and agents already observed listening get no offer. Existing BYO identity +and credentials are retained. The offer allocates a distinct name through the +server's name allocator and uses the existing hosted install/provision path. +Hosting errors preserve the command and expose a retry beside the offer. The allocated hosted name is retained for the issued key, so a provision failure retries the existing installation instead of allocating another cap slot. + +The placement invariant is checked in real Chromium by +`e2e/byo-offer-layout.spec.ts`: with all command blocks expanded, each block's +viewport top must be identical before and after the offer appears. The memory +textarea, final action row and both action buttons must also stay stationary, +with those controls fully in the viewport while the timer fires. The bar must +remain visible at both scroll extremes and must not cover the last control at +full scroll; the reserved height must equal its measured height. The DOM order +is also pinned in the BYO render and v2 layout-invariant Jest tests. diff --git a/e2e/byo-offer-layout.spec.ts b/e2e/byo-offer-layout.spec.ts new file mode 100644 index 0000000000..1861b354d3 --- /dev/null +++ b/e2e/byo-offer-layout.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@playwright/test'; + +// Real layout with a synthetic user and intercepted APIs; no backend write or +// runtime credential is needed. Run against the frontend's normal dev server. +for (const width of [1440, 390]) { + test(`BYO offer leaves existing commands and controls stationary at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 }); + await page.clock.install(); + await page.addInitScript(() => { + localStorage.setItem('token', 'fixture-only-user'); + localStorage.setItem('v2.lang', 'en'); + }); + await page.route('**/api/**', async (route) => { + const { pathname, searchParams } = new URL(route.request().url()); + let data: unknown = {}; + if (pathname === '/api/auth/user') data = { _id: 'fixture-owner', username: 'alex', email: 'fixture@example.invalid', verified: true }; + else if (pathname === '/api/pods') data = [{ _id: 'fixture-pod', name: 'Workspace', type: 'chat', createdBy: { _id: 'fixture-owner' } }]; + else if (pathname === '/api/hosted/availability') data = { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 200 } }; + else if (pathname === '/api/machines') data = { machines: [] }; + else if (pathname.endsWith('next-free-name')) data = { name: searchParams.get('base') }; + else if (pathname.includes('runtime-tokens')) data = { token: 'fixture-only-key' }; + else if (pathname.includes('attention')) data = { items: [], count: 0 }; + await route.fulfill({ json: data }); + }); + await page.goto('/v2/agents/byo'); + const initialReservedHeight = await page.locator('.v2-byo__layout').evaluate((el) => + parseFloat(getComputedStyle(el).paddingBottom) - 40); + await page.getByTestId('byo-mode-byo').click(); + await page.getByRole('button', { name: 'Get my connect command', exact: true }).click(); + const result = page.locator('.v2-byo__result'); + await expect(result).toBeVisible(); + // Fix the setup OS, independently of the runner's emulated user agent. + await page.getByRole('button', { name: 'Mac', exact: true }).click(); + // Include commands inside Other ways: checking only the first command + // missed the original regression, where later copy targets moved. + await result.locator('details').evaluateAll((details) => details.forEach((el) => { (el as HTMLDetailsElement).open = true; })); + await page.evaluate(() => document.fonts.ready); + // Put the memory field and action buttons on screen while the timer fires. + await result.locator('.v2-byo__memory-text').scrollIntoViewIfNeeded(); + await expect(result.locator('.v2-byo__memory-text')).toBeInViewport({ ratio: 1 }); + for (const button of await result.locator('.v2-byo__cta-row button').all()) { + await expect(button).toBeInViewport({ ratio: 1 }); + } + const commands = result.locator('.v2-byo__command'); + await expect(commands).toHaveCount(4); + const before = await commands.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)); + const controls = result.locator('.v2-byo__memory-text, .v2-byo__cta-row, .v2-byo__cta-row button'); + await expect(controls).toHaveCount(4); + const controlsBefore = await controls.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)); + await expect(page.locator('.v2-byo__hosted-offer')).toHaveCount(0); + await page.clock.fastForward(30_001); + await expect(page.getByRole('status', { name: 'Run it here instead' })).toBeAttached(); + const after = await commands.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)); + const controlsAfter = await controls.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top)); + await testInfo.attach('command-top-positions', { + body: Buffer.from(JSON.stringify({ width, before, after, controlsBefore, controlsAfter }, null, 2)), + contentType: 'application/json', + }); + expect(after).toEqual(before); + expect(controlsAfter).toEqual(controlsBefore); + const bar = page.getByRole('status', { name: 'Run it here instead' }); + await expect(bar).toBeInViewport({ ratio: 1 }); + const pane = page.locator('.v2-feature__body'); + await pane.evaluate((el) => { el.scrollTop = 0; }); + await expect(bar).toBeInViewport({ ratio: 1 }); + await pane.evaluate((el) => { el.scrollTop = el.scrollHeight; }); + await expect(bar).toBeInViewport({ ratio: 1 }); + const lastControl = result.locator('.v2-byo__cta-row button').last(); + await expect(lastControl).toBeInViewport({ ratio: 1 }); + const controlBox = await lastControl.boundingBox(); + const barBox = await bar.boundingBox(); + expect(controlBox!.y + controlBox!.height).toBeLessThanOrEqual(barBox!.y); + const reservedHeight = await page.locator('.v2-byo__layout').evaluate((el) => + parseFloat(getComputedStyle(el).paddingBottom) - 40); + expect(reservedHeight).toBe(barBox!.height); + expect(initialReservedHeight).toBe(barBox!.height); + const paneBox = await pane.boundingBox(); + expect(Math.abs(barBox!.y + barBox!.height - paneBox!.y - paneBox!.height)).toBeLessThanOrEqual(1); + expect(await bar.evaluate((el) => getComputedStyle(el).boxShadow)).toBe('none'); + await testInfo.attach('sticky-bar-clearance', { + body: Buffer.from(JSON.stringify({ width, barHeight: barBox!.height, reservedHeight, + barTop: barBox!.y, lastControlBottom: controlBox!.y + controlBox!.height }, null, 2)), + contentType: 'application/json', + }); + + if (width === 390) { + const provisionError = 'The hosted service could not start this agent. Please try again in a few minutes.'; + await page.route('**/api/registry/agents/next-free-name?*', (route) => route.fulfill({ json: { name: 'alex-hosted-agent' } })); + await page.route('**/api/hosted/provision', (route) => route.fulfill({ status: 503, json: { error: provisionError } })); + await bar.getByRole('button', { name: 'Run it here instead', exact: true }).click(); + const error = bar.locator('.v2-byo__error'); + await expect(error).toHaveText(provisionError); + await expect(error).toBeInViewport({ ratio: 1 }); + const errorGeometry = await error.evaluate((el) => ({ + height: el.getBoundingClientRect().height, + lineHeight: parseFloat(getComputedStyle(el).lineHeight), + })); + expect(errorGeometry.height).toBeGreaterThanOrEqual(errorGeometry.lineHeight * 2); + expect(await bar.evaluate((el) => el.scrollHeight <= el.clientHeight + 1)).toBe(true); + expect(await bar.evaluate((el) => getComputedStyle(el).overflowY)).toBe('visible'); + await testInfo.attach('phone-two-line-provision-error', { body: await page.screenshot({ path: testInfo.outputPath('phone-two-line-provision-error.png') }), contentType: 'image/png' }); + await expect(bar).toBeInViewport({ ratio: 1 }); + await expect(bar.getByRole('button', { name: 'Run it here instead', exact: true })).toBeInViewport({ ratio: 1 }); + } + + }); +} diff --git a/frontend/design-system/preview/components-byo-hosted-bar.html b/frontend/design-system/preview/components-byo-hosted-bar.html new file mode 100644 index 0000000000..4bb0180428 --- /dev/null +++ b/frontend/design-system/preview/components-byo-hosted-bar.html @@ -0,0 +1,8 @@ +BYO hosted bar · Signal

BYO hosted alternative

Permanent bottom space equals the sticky bar height. Scroll to the final control.

Your connect command

commonly agent run my-agent

No terminal needed. Start a new hosted agent in this pod.

diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9350553c83..67424d3ef1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1557,6 +1557,8 @@ "machineMeta": "Needs the Commonly daemon running there" }, "hosted": { + "offerAction": "Run it here instead", + "offerBody": "No terminal needed. Start a new hosted agent in this pod.", "heading": "Running:", "live": "{{name}} is live in {{pod}}. Mention it there and it answers.", "starting": "Starting {{name}}…", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2301cbdc4b..3a8f8dd24d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1617,6 +1617,8 @@ "machineMeta": "需要那台电脑运行 Commonly 守护进程" }, "hosted": { + "offerAction": "改为在这里运行", + "offerBody": "无需终端。在这个 Pod 中启动一个新的托管智能体。", "heading": "运行中:", "live": "{{name}} 已在 {{pod}} 上线。在那里 @ 它即可得到回复。", "starting": "正在启动 {{name}}…", diff --git a/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx index fdc9ddabd2..46703dc364 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // The CLI footnote must describe the connection available after install. // `agent init` scaffolds a Python webhook and does not connect Claude/Codex. import React from 'react'; diff --git a/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx index 4938430d02..db96709fe4 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOFromCard.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // QA BYO walk 2 (2026-10-16): after approving Scout's connect card, the page // still asked "Where should it run?" and showed the name and pod fields, 127 // words before the button, most of them re-asking what the card settled. diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index e0657fcd15..45220a6f4d 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // ADR-023 W2 "Run it here" on the BYO page. The instance reports whether it // can host; when it can, the page asks where the agent should run with // nothing preselected (ruled 2026-10-14), and choosing "Run it here" installs diff --git a/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx new file mode 100644 index 0000000000..7484c32a10 --- /dev/null +++ b/frontend/src/v2/__tests__/V2AgentBYOHostedOffer.test.tsx @@ -0,0 +1,168 @@ +// @ts-nocheck +// ADR-023 W2 "Run it here" on the BYO page. The instance reports whether it +// can host; when it can, the page asks where the agent should run with +// nothing preselected (ruled 2026-10-14), and choosing "Run it here" installs +// with runtimeType 'hosted', asks the kernel to provision, and never renders a +// token. When it cannot, the page is exactly the BYO page it was. +import React, { StrictMode } from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import V2AgentBYO from '../components/V2AgentBYO'; +import { AuthContext } from '../../context/AuthContext'; + +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 pods = [{ _id: 'p1', name: 'Workspace', type: 'chat', createdBy: { _id: 'u1' } }]; + +const mockGet = ({ configured, status }) => { + axios.get.mockImplementation((url) => { + if (url.startsWith('/api/registry/agents/next-free-name?')) return Promise.resolve({ data: { name: new URLSearchParams(url.split('?')[1]).get('base') } }); + if (url.startsWith('/api/hosted/availability')) { + return Promise.resolve({ data: { configured, caps: { agentsPerUser: 1, turnsPerDay: 200 } } }); + } + if (url.startsWith('/api/hosted/status')) return Promise.resolve({ data: status || { runtime: {} } }); + if (url === '/api/pods') return Promise.resolve({ data: pods }); + return Promise.resolve({ data: {} }); + }); +}; + +const renderPage = () => render( + + + + + , +); + +beforeEach(() => { + sessionStorage.clear(); + window.matchMedia = jest.fn().mockReturnValue({ matches: true }); + jest.useFakeTimers(); mockGet({ configured: true }); + axios.post.mockImplementation((url) => Promise.resolve({ data: url.includes('runtime-tokens') ? { token: 'fixture-only-key' } : { ready: true } })); +}); +afterEach(() => { jest.useRealTimers(); jest.resetAllMocks(); }); +const issue = async () => { + const view = renderPage(); await screen.findByTestId('byo-mode-byo'); fireEvent.click(screen.getByTestId('byo-mode-byo')); + 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); + return view; +}; +const offer = () => screen.queryByRole('button', { name: 'Run it here instead' }); +const tick = async (ms) => act(async () => { jest.advanceTimersByTime(ms); }); +test('30s offer starts a distinct hosted identity in the same pod', async () => { + await issue(); await tick(29999); expect(offer()).not.toBeInTheDocument(); await tick(1); fireEvent.click(offer()); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/hosted/provision', { agentName: 'sam-agent' }, expect.anything())); + expect(axios.post).toHaveBeenCalledWith('/api/registry/install', expect.objectContaining({ agentName: 'sam-agent', podId: 'p1', config: { runtime: { runtimeType: 'hosted' } } }), expect.anything()); + await waitFor(() => expect(screen.queryByText(/your connect command for/i)).not.toBeInTheDocument()); +}); +test('exit intent offers immediately; manual copy dismisses permanently', async () => { + await issue(); fireEvent.mouseLeave(document.documentElement, { clientY: 0 }); expect(offer()).toBeInTheDocument(); + fireEvent.copy(screen.getByTestId('byo-listen').querySelector('.v2-byo__command')); await tick(60000); fireEvent.mouseLeave(document.documentElement, { clientY: 0 }); expect(offer()).not.toBeInTheDocument(); +}); +test('button copy suppresses the offer beyond the Copied label', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } }); + await issue(); await act(async () => fireEvent.click(screen.getByTestId('byo-listen').querySelector('button.v2-byo__copy'))); await tick(60000); expect(offer()).not.toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledWith('/api/stats/byo-step', { step: 'cli-command-copied' }, expect.anything()); +}); +test('hosting errors preserve the command and a retryable offer', async () => { + await issue(); axios.post.mockImplementation((url) => url === '/api/registry/install' ? Promise.reject({ response: { data: { code: 'hosted_cap_reached', cap: 1 } } }) : Promise.resolve({ data: {} })); + await tick(30000); fireEvent.click(offer()); expect(await screen.findByRole('alert')).toHaveTextContent(/hosted/i); expect(screen.getByText(/your connect command for/i)).toBeInTheDocument(); expect(offer()).toBeEnabled(); + expect(axios.post.mock.calls.some(([url]) => url === '/api/hosted/provision')).toBe(false); +}); +test('page entry is recorded once under StrictMode; command rendering is separate and contains no key', async () => { + render(); + await screen.findByTestId('byo-mode-byo'); fireEvent.click(screen.getByTestId('byo-mode-byo')); + 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); + expect(axios.post.mock.calls.filter(([url]) => url === '/api/stats/byo-step').map(([, body]) => body)).toEqual([{ step: 'page-viewed' }, { step: 'seat-created' }]); +}); +test('a failed clipboard write leaves the fallback available', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockRejectedValue(new Error('Denied')) } }); + await issue(); await act(async () => fireEvent.click(screen.getByTestId('byo-listen').querySelector('button.v2-byo__copy'))); await tick(30000); expect(offer()).toBeInTheDocument(); +}); +test('unavailable hosting never gets offered', async () => { + mockGet({ configured: false }); renderPage(); + 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); + await tick(30000); fireEvent.mouseLeave(document.documentElement, { clientY: 0 }); expect(offer()).not.toBeInTheDocument(); +}); +test('an agent that connects before the deadline needs no hosted offer', async () => { + await issue(); const get = axios.get.getMockImplementation(); + axios.get.mockImplementation((url) => url === '/api/users/me/agent-connection' ? Promise.resolve({ data: { connected: true, connectedAgent: { agentName: 'sam-claude' }, lastUsedAt: new Date(Date.now() + 1).toISOString() } }) : get(url)); + await tick(4000); await screen.findByTestId('byo-listen-ok'); await tick(30000); expect(offer()).not.toBeInTheDocument(); +}); +test('double activation waits for one name lookup and one hosted provision', async () => { + await issue(); await tick(30000); const get = axios.get.getMockImplementation(); let resolveName; + axios.get.mockImplementation((url) => url.startsWith('/api/registry/agents/next-free-name?') ? new Promise(resolve => { resolveName = resolve; }) : get(url)); + const button = offer(); fireEvent.click(button); fireEvent.click(button); + expect(button).toBeDisabled(); await act(async () => resolveName({ data: { name: 'sam-agent' } })); + await waitFor(() => expect(axios.post.mock.calls.filter(([url]) => url === '/api/hosted/provision')).toHaveLength(1)); +}); +test('name lookup failure never provisions or changes the issued command', async () => { + await issue(); await tick(30000); const get = axios.get.getMockImplementation(); + axios.get.mockImplementation((url) => url.startsWith('/api/registry/agents/next-free-name?') ? Promise.reject(new Error('Offline')) : get(url)); + fireEvent.click(offer()); expect(await screen.findByRole('alert')).toHaveTextContent(/name/i); + expect(screen.getByText(/your connect command for/i)).toBeInTheDocument(); expect(offer()).toBeEnabled(); expect(axios.post.mock.calls.some(([url]) => url === '/api/hosted/provision')).toBe(false); +}); +test('offer follows every command and existing control and stays absent after remount in the same session', async () => { + const view = await issue(); await tick(30000); + const row = screen.getByRole('status', { name: 'Run it here instead' }); + expect(row.parentElement.lastElementChild).toBe(row); + document.querySelectorAll('.v2-byo__command, .v2-byo__memory-text, .v2-byo__cta-row').forEach((command) => { + expect(command.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + view.unmount(); await issue(); await tick(30000); fireEvent.mouseLeave(document.documentElement, { clientY: 0 }); expect(offer()).not.toBeInTheDocument(); +}); +test('copy before the offer also suppresses it across a remount', async () => { + const view = await issue(); fireEvent.copy(screen.getByTestId('byo-listen').querySelector('.v2-byo__command')); + view.unmount(); await issue(); await tick(30000); expect(offer()).not.toBeInTheDocument(); +}); +test('phone exit does not trigger the offer; its timer still does', async () => { + window.matchMedia = jest.fn().mockReturnValue({ matches: false }); + await issue(); fireEvent.mouseLeave(document.documentElement, { clientY: 0 }); expect(offer()).not.toBeInTheDocument(); await tick(30000); expect(offer()).toBeInTheDocument(); +}); +test('failed provisioning retries the same allocated hosted identity instead of consuming another cap slot', async () => { + await issue(); await tick(30000); const get = axios.get.getMockImplementation(); let lookups = 0; let installs = 0; let provisions = 0; + axios.get.mockImplementation((url) => url.startsWith('/api/registry/agents/next-free-name?') ? Promise.resolve({ data: { name: ++lookups === 1 ? 'sam-agent' : 'sam-agent-2' } }) : get(url)); + axios.post.mockImplementation((url) => { + if (url === '/api/registry/install' && ++installs > 1) return Promise.reject({ response: { data: { error: 'Agent already installed in this pod' } } }); + if (url === '/api/hosted/provision' && ++provisions === 1) return Promise.reject({ response: { data: { error: 'Runtime temporarily unreachable' } } }); + return Promise.resolve({ data: { ready: true } }); + }); + fireEvent.click(offer()); await screen.findByRole('alert'); fireEvent.click(offer()); + await waitFor(() => expect(screen.queryByText(/your connect command for/i)).not.toBeInTheDocument()); + expect(lookups).toBe(1); + expect(axios.post.mock.calls.filter(([url]) => url === '/api/hosted/provision').map(([,body]) => body.agentName)).toEqual(['sam-agent', 'sam-agent']); +}); +test('copying a heading or memory text does not count as copying the command', async () => { + await issue(); fireEvent.copy(screen.getByText(/your connect command for/i)); + fireEvent.copy(document.querySelector('.v2-byo__memory-text')); + await tick(30000); expect(offer()).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index c86e6b97aa..e21c79987b 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // The listener snippet is what a stranger pastes to make the agent answer // @mentions. Every line of it has to execute: the CLI-install line was a shell // comment until 2026-10-09, so a whole-snippet paste on a machine without the diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx index 652997ccd3..322c9c0b32 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenVerify.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // The listening-verification loop on the BYO page (#887 class, second half). // #888 made the listening step legible; this pins the step being CHECKABLE: // the page polls /api/users/me/agent-connection until THIS agent's runtime diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx index ff2c219172..c1f6e88dc9 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // ADR-026 Phase 2 slice 3: the "on my computer" path. Pinned: the mode card // appears only when the user has daemon machines; submit installs with // runtimeType 'wrapper' and files a placement request (never a binding); the diff --git a/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx index 67283c8dc8..7cc779eef0 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMemory.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // Retention plan Phase C: the optional "bring its memory" step on the BYO // page. Pins the wire contract: the import uses the freshly issued // cm_agent_* runtime token (NOT the user JWT), patch mode, sourceRuntime diff --git a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx index 73b48d8af5..f615ee0f61 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOName.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // The default agent name follows where it runs (C14 rehearsal, 2026-10-20). // "Run it here" takes `-agent`. When "Use my own agent" defaulted to the // same name, a person who had just started a hosted agent was refused at the diff --git a/frontend/src/v2/__tests__/V2AgentBYOPersona.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOPersona.test.tsx index b3e6206ff9..c025c2634f 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOPersona.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOPersona.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // Sam (2026-09-01): "Hire an agent" and "Add a computer" converged on this // page with the persona silently dropped. Pinned: a ?persona= param renders // a context card, names the agent after the persona, and the diff --git a/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx index 8a6443fa95..1e2afb9908 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx @@ -1,4 +1,6 @@ // @ts-nocheck +// Keep setup request assertions independent of funnel telemetry. +jest.mock('../utils/byoTelemetry', () => ({ recordByoStep: jest.fn() })); // Guards the BYO pod-picker default after the 2026-07-24 launch incident: // new users auto-join Commonly HQ (publicRead), it sorts first by activity in // /api/pods, and the old `installablePods[0]` default silently attached diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 8134ecdf92..1a43be48e0 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4938,3 +4938,28 @@ test('TASK-443 lease typography outranks the ownership cell font size', () => { expect(ruleBody(read('../v2.css'), '.v2-board__ownership .v2-board__lease-value')) .toContain('font: 500 12px/16px var(--v2-font-mono)'); }); + +test('TASK-482 sticky bar overlays reserved space with a top border and no shadow', () => { + const css = read('../v2.css'); + const bar = ruleBody(css, '.v2-byo__hosted-offer'); + expect(bar).toContain('position: sticky'); + expect(bar).toContain('bottom: 0'); + expect(bar).toContain('min-height: var(--v2-byo-offer-height)'); + expect(bar).not.toMatch(/(?:^|[;\s])height:/); + expect(bar).not.toContain('overflow:'); + expect(bar).toContain('margin-top: calc(-1 * var(--v2-byo-offer-height))'); + expect(bar).toContain('border-top: 1px solid var(--v2-border)'); + expect(bar).toContain('background: var(--v2-surface)'); + expect(bar).not.toContain('box-shadow'); + expect(ruleBody(css, '.v2-byo__layout')).toContain('padding: 8px 24px calc(40px + var(--v2-byo-offer-height, 0px))'); +}); + +test('TASK-482 offer follows the final CTA so existing commands and controls cannot move', () => { + const source = read('../components/V2AgentBYO.tsx'); + const finalCommand = source.lastIndexOf('className="v2-byo__command"'); + const offer = source.indexOf('role="status" className="v2-byo__hosted-offer"'); + expect(finalCommand).toBeGreaterThan(-1); + expect(offer).toBeGreaterThan(finalCommand); + expect(offer).toBeGreaterThan(source.lastIndexOf('className="v2-byo__cta-row"')); + // Actual before/after top coordinates are asserted by e2e/byo-offer-layout.spec.ts. +}); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 22937647c0..9dcd85ab79 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -15,10 +15,11 @@ // expected to paste into their MCP host config immediately. If lost, they // can explicitly replace the old key without changing the agent identity. -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; +import { recordByoStep, type ByoStep } from '../utils/byoTelemetry'; import getApiBaseUrl from '../../utils/apiBaseUrl'; import V2FeaturePage from './V2FeaturePage'; import V2Avatar from './V2Avatar'; @@ -180,6 +181,16 @@ const V2AgentBYO: React.FC = () => { const [error, setError] = useState(null); const [errorField, setErrorField] = useState<'machine' | 'name' | 'pod' | null>(null); const [issued, setIssued] = useState<{ token: string; agentName: string; podId: string; issuedAt: number } | null>(null); + const [hasCopied, setHasCopied] = useState(false); + const [showHostedOffer, setShowHostedOffer] = useState(false); + const offeredPanels = useRef(new Set()); + const offerSessionKey = issued + ? `commonly:byo-hosted-offer:${currentUser?._id || ''}:${issued.podId}:${issued.agentName}` + : null; + const viewed = useRef(false); + const commandViewed = useRef(null); + const fallbackStarting = useRef(false); + const allocatedHosted = useRef<{ issued: object; name: string } | null>(null); const [copied, setCopied] = useState(null); // Which CLI the listener runs. Pinned in the command (`--adapter`), so a Mac // without Claude Code says so instead of quietly starting Codex (QA BYO @@ -556,6 +567,7 @@ const V2AgentBYO: React.FC = () => { } const provisioned = await api.post<{ ready?: boolean }>('/api/hosted/provision', { agentName: cleanName }); setHosted({ agentName: cleanName, podId, ready: provisioned?.ready === true }); + return true; } catch (err) { const e = err as { response?: { data?: { error?: string; message?: string; code?: string } }; message?: string }; const code = e.response?.data?.code; @@ -567,6 +579,35 @@ const V2AgentBYO: React.FC = () => { } }; + const startHostedInstead = async () => { + if (!issued || submitting || fallbackStarting.current) return; + fallbackStarting.current = true; + setSubmitting(true); + setError(null); + try { + // A BYO installation cannot be provisioned as hosted. Allocate a new + // identity through the same server name allocator as the setup form. + if (allocatedHosted.current?.issued !== issued) { + const base = userSlug ? `${userSlug}-agent` : 'hosted-agent'; + const available = await api.get<{ name: string }>( + `/api/registry/agents/next-free-name?base=${encodeURIComponent(base)}`, + ); + if (!available.name || available.name === issued.agentName) { + throw new Error(t('agentByo.errors.nameLookupFailed')); + } + allocatedHosted.current = { issued, name: available.name }; + } + // An install can succeed while provision fails. Reuse its name on retry + // so the existing-installation path can finish within the hosted cap. + if (await submitHosted(allocatedHosted.current.name)) setIssued(null); + } catch { + setError(t('agentByo.errors.nameLookupFailed')); + } finally { + fallbackStarting.current = false; + setSubmitting(false); + } + }; + // On-my-computer path (ADR-026 Phase 2): install with runtimeType 'wrapper' // + persona, then file the placement request. The daemon on that machine // adopts (D3 CAS), mints the credential server-side (nothing secret renders @@ -839,13 +880,63 @@ const V2AgentBYO: React.FC = () => { // BYO funnel telemetry: 7 of 7 recent seats died between token issuance and // the first authenticated call, and the server cannot see whether the user // even copied a command. Fire-and-forget; the flow never waits on it. - const byoStep = (step: string) => { - axios.post('/api/stats/byo-step', { step }).catch(() => {}); + const byoStep = (step: ByoStep) => { + recordByoStep(api, step); + }; + + useEffect(() => { + if (!currentUser || viewed.current) return; + viewed.current = true; + recordByoStep(api, 'page-viewed'); + }, [api, currentUser]); + + useEffect(() => { + if (commandViewed.current === issued) return; + commandViewed.current = issued; + allocatedHosted.current = null; + setHasCopied(false); + setShowHostedOffer(false); + if (!issued) return; + // This event means the issued command rendered, distinct from page entry. + recordByoStep(api, 'seat-created'); + }, [api, issued]); + + const rememberHostedOffer = () => { + if (!offerSessionKey) return; + offeredPanels.current.add(offerSessionKey); + try { sessionStorage.setItem(offerSessionKey, 'seen'); } catch { /* Storage can be disabled. */ } + }; + + useEffect(() => { + if (!issued || !offerSessionKey || hasCopied || !hosting?.configured || listenState === 'listening') return undefined; + const offer = () => { + if (offeredPanels.current.has(offerSessionKey)) return; + try { if (sessionStorage.getItem(offerSessionKey)) return; } catch { /* Use the panel guard. */ } + offeredPanels.current.add(offerSessionKey); + try { sessionStorage.setItem(offerSessionKey, 'seen'); } catch { /* Use the panel guard. */ } + setShowHostedOffer(true); + }; + const timer = window.setTimeout(offer, 30_000); + const leaving = (event: MouseEvent) => { + if (event.clientY <= 0 && window.matchMedia('(min-width: 761px)').matches) offer(); + }; + document.documentElement.addEventListener('mouseleave', leaving); + return () => { + window.clearTimeout(timer); + document.documentElement.removeEventListener('mouseleave', leaving); + }; + }, [issued, offerSessionKey, hasCopied, hosting?.configured, listenState]); + + const copiedIssuedContent = () => { + rememberHostedOffer(); + setHasCopied(true); + setShowHostedOffer(false); }; const copy = async (key: string, value: string) => { try { await navigator.clipboard.writeText(value); + copiedIssuedContent(); setCopied(key); setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500); if (key === 'claude' || key === 'cursor') byoStep('mcp-command-copied'); @@ -919,6 +1010,7 @@ const V2AgentBYO: React.FC = () => { return t('agentByo.description'); })()} showPodsSidebar={false} + className="v2-feature--byo" >
@@ -1367,6 +1459,7 @@ const V2AgentBYO: React.FC = () => {

{t('agentByo.result.heading')} {issued.agentName}

+ {/* Above the copy sits only what the paste needs: where, what it needs, the command (QA BYO walk 2: 112 words down to the box). What it @@ -1420,7 +1513,7 @@ const V2AgentBYO: React.FC = () => {

{listenerAvailable && ( -
+
{listenSnippet}
{t('agentByo.snippets.cursorInstall')}

{t('agentByo.snippets.cursorInstallHint')}

-
+
{cursorSnippet}
+
)}
@@ -1597,6 +1692,15 @@ const V2AgentBYO: React.FC = () => {
+ {issued && showHostedOffer && !hasCopied && hosting?.configured && listenState !== 'listening' && ( +
+

{t('agentByo.hosted.offerBody')}

+ + {error &&

{error}

} +
+ )} ); }; diff --git a/frontend/src/v2/utils/byoTelemetry.ts b/frontend/src/v2/utils/byoTelemetry.ts new file mode 100644 index 0000000000..919d826f76 --- /dev/null +++ b/frontend/src/v2/utils/byoTelemetry.ts @@ -0,0 +1,9 @@ +import type { V2ApiClient } from '../hooks/useV2Api'; + +export type ByoStep = 'page-viewed' | 'seat-created' | 'mcp-command-copied' | 'cli-command-copied' + | 'token-copied' | 'listen-confirmed' | 'listen-timeout'; + +// Fixed event names only. Commands and credentials must never enter telemetry. +export const recordByoStep = (api: V2ApiClient, step: ByoStep): void => { + void api.post('/api/stats/byo-step', { step }).catch(() => {}); +}; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 665496d1d0..3acf960f31 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -5977,6 +5977,47 @@ body.modern-ui.v2-canvas { /* Sprint B3 — Bring-your-own MCP agent onboarding page. Lives at * /v2/agents/byo. Single-column form + result. Aligned with v2's * border-not-shadow aesthetic; accent-blue for interactive controls. */ +/* The bar's space exists before it appears; its negative margin makes the + sticky overlay occupy no additional flow space when it becomes visible. */ +.v2-feature--byo { --v2-byo-offer-height: 96px; } +.v2-feature--byo > .v2-feature__body { padding-bottom: 0; } +.v2-byo__hosted-offer { + position: sticky; + bottom: 0; + z-index: 5; + box-sizing: border-box; + min-height: var(--v2-byo-offer-height); + margin-top: calc(-1 * var(--v2-byo-offer-height)); + padding: 12px 24px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + align-content: center; + gap: 8px 16px; + border-top: 1px solid var(--v2-border); + background: var(--v2-surface); +} +.v2-byo__hosted-offer > p:first-child { + margin: 0; + color: var(--v2-text-muted); + font: 400 13px/20px var(--v2-font); +} +.v2-root .v2-byo__hosted-offer button.v2-byo__hosted-offer-action { + height: 40px; + padding: 0 16px; + align-self: center; +} +.v2-byo__hosted-offer .v2-byo__error { + grid-column: 1 / -1; + margin: 0; + padding: 0; + border: 0; +} +@media (max-width: 760px) { + .v2-feature--byo { --v2-byo-offer-height: 128px; } + .v2-byo__hosted-offer { grid-template-columns: minmax(0, 1fr); padding: 12px 16px; } + .v2-root .v2-byo__hosted-offer button.v2-byo__hosted-offer-action { height: 44px; width: 100%; } +} .v2-byo__form, .v2-byo__result { display: flex; flex-direction: column; @@ -6138,7 +6179,7 @@ body.modern-ui.v2-canvas { max-width: 1060px; margin: 0 auto; align-items: start; - padding: 8px 24px 40px; + padding: 8px 24px calc(40px + var(--v2-byo-offer-height, 0px)); } /* Signal rule 4 — avatars are a 4px square frame. All four avatars on this route are in scope together (the persona context, the placed and hosted