From 1ad4563504764ace115f4368b128fe339dcd91b6 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 22 Oct 2026 23:41:41 -0700 Subject: [PATCH 01/12] Add the /download page for the desktop app Co-Authored-By: Claude Opus 5.5 --- router/index.ts | 12 +- router/test/agent-chat.test.ts | 6 +- web/app/download/DownloadButton.tsx | 85 ++++++++++++++ web/app/download/download.module.css | 68 ++++++++++++ web/app/download/page.tsx | 111 +++++++++++++++++++ web/app/sitemap.ts | 1 + web/components/SiteFooter.tsx | 1 + web/components/home/HomeSections.tsx | 8 +- web/components/home/home-sections.module.css | 7 ++ web/content/docs/relayhistory/mcp.mdx | 2 +- web/lib/agent-signup.ts | 3 +- web/lib/desktop-downloads.ts | 71 ++++++++++++ web/lib/test/desktop-downloads.test.ts | 65 +++++++++++ 13 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 web/app/download/DownloadButton.tsx create mode 100644 web/app/download/download.module.css create mode 100644 web/app/download/page.tsx create mode 100644 web/lib/desktop-downloads.ts create mode 100644 web/lib/test/desktop-downloads.test.ts diff --git a/router/index.ts b/router/index.ts index a7e83ce6..fff7b253 100644 --- a/router/index.ts +++ b/router/index.ts @@ -54,7 +54,11 @@ const HANDLE_SEGMENT = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])"; const RESERVED_AGENT_PAGE_HANDLES = new Set([ "register", "connect", "api", "cloud", "admin", "www", "help", "support", "docs", "status", "security", "abuse", "login", "signup", "agents", "directory", + "download", ]); +// Desktop download links (release READMEs, the Windows placeholder app) name +// arelay.to/download; the page lives on the primary host. +const DOWNLOAD_PAGE_PATH = "/download"; // The verified-agent directory page. web/lib/agent-directory.ts names the same // path (AGENT_DIRECTORY_PATH); keep the two in step. const AGENT_DIRECTORY_PAGE_PATH = "/directory"; @@ -356,9 +360,13 @@ export function isRelayAgentRegistryRoute( return REGISTRY_API_PATH.test(pathname); } -// arelay.to only fronts agent chat: its root, its www alias and the signed-in -// cloud app redirect to the primary host. +// arelay.to only fronts agent chat: its root, the download page, its www alias +// and the signed-in cloud app redirect to the primary host. export function getShortHostRedirect(url: URL): string | undefined { + const shortHost = url.hostname === SHORT_HOST || url.hostname === SHORT_HOST_WWW; + if (shortHost && url.pathname.replace(/\/$/, "") === DOWNLOAD_PAGE_PATH) { + return `https://${PRIMARY_HOST}${DOWNLOAD_PAGE_PATH}${url.search}`; + } if (url.hostname === SHORT_HOST_WWW) { return `https://${SHORT_HOST}${url.pathname}${url.search}`; } diff --git a/router/test/agent-chat.test.ts b/router/test/agent-chat.test.ts index 73a98514..8e2c2b1d 100644 --- a/router/test/agent-chat.test.ts +++ b/router/test/agent-chat.test.ts @@ -317,6 +317,10 @@ describe("router agent chat", () => { expect(getShortHostRedirect(new URL("https://www.arelay.to/agent-relay"))).toBe("https://arelay.to/agent-relay"); expect(getShortHostRedirect(new URL("https://arelay.to/agent-relay"))).toBeUndefined(); expect(getShortHostRedirect(new URL("https://agentrelay.com/"))).toBeUndefined(); + expect(getShortHostRedirect(new URL("https://arelay.to/download"))).toBe("https://agentrelay.com/download"); + expect(getShortHostRedirect(new URL("https://arelay.to/download/?os=linux"))).toBe("https://agentrelay.com/download?os=linux"); + expect(getShortHostRedirect(new URL("https://www.arelay.to/download"))).toBe("https://agentrelay.com/download"); + expect(getShortHostRedirect(new URL("https://agentrelay.com/download"))).toBeUndefined(); }); it("serves agent pages at the arelay.to root and under /u on agentrelay.com", () => { @@ -381,7 +385,7 @@ describe("router agent chat", () => { expect(getAgentPagePath("arelay.to", "/agent-relay/bridge.sh", "GET", "*/*")).toBeUndefined(); expect(getAgentPagePath("arelay.to", "/-invalid", "GET", "*/*")).toBeUndefined(); expect(getAgentPagePath("arelay.to", "/x", "GET", "*/*")).toBeUndefined(); - for (const reserved of ["register", "connect", "api", "cloud", "admin", "www", "help", "support", "docs", "status", "security", "abuse", "login", "signup"]) { + for (const reserved of ["register", "connect", "api", "cloud", "admin", "www", "help", "support", "docs", "status", "security", "abuse", "login", "signup", "download"]) { expect(getAgentPagePath("arelay.to", `/${reserved}`, "GET", "*/*")).toBeUndefined(); } expect(getAgentPagePath("arelay.to", "/agent-relay", "POST", "*/*")).toBeUndefined(); diff --git a/web/app/download/DownloadButton.tsx b/web/app/download/DownloadButton.tsx new file mode 100644 index 00000000..e608fe9a --- /dev/null +++ b/web/app/download/DownloadButton.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { + alternateDesktopAsset, + type DesktopPlatform, + desktopAssetUrl, + detectDesktopPlatform, + primaryDesktopAsset, +} from '../../lib/desktop-downloads'; +import s from './download.module.css'; + +type UADataNavigator = Navigator & { + userAgentData?: { getHighEntropyValues(hints: string[]): Promise<{ architecture?: string }> }; +}; + +/** What the server renders, and what a visitor whose OS can't be read gets: every platform. */ +const EVERY_PLATFORM: readonly DesktopPlatform[] = [ + { os: 'macos', arch: 'arm64' }, + { os: 'linux', arch: 'x64' }, + { os: 'windows', arch: 'x64' }, +]; + +/** Leads with the build for the visitor's OS and architecture. */ +export function DownloadButton() { + const [platform, setPlatform] = useState(null); + + useEffect(() => { + let cancelled = false; + const nav = navigator as UADataNavigator; + const detect = (architecture?: string) => { + if (!cancelled) { + setPlatform(detectDesktopPlatform(nav.userAgent, { architecture, touchPoints: nav.maxTouchPoints })); + } + }; + detect(); + nav.userAgentData + ?.getHighEntropyValues(['architecture']) + .then(({ architecture }) => detect(architecture || undefined)) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + if (!platform) { + return ( +
+
+ {EVERY_PLATFORM.map((choice) => { + const asset = primaryDesktopAsset(choice); + return ( + + {asset.label} + {asset.kind} + + ); + })} +
+

+ Other builds are in all downloads. +

+
+ ); + } + + const asset = primaryDesktopAsset(platform); + const alternate = alternateDesktopAsset(platform); + + return ( +
+ + Download for {asset.label} + +

+ {asset.kind} · SHA-256 · Not your machine?{' '} + + {alternate.label} {alternate.kind} + {' '} + or all downloads +

+
+ ); +} diff --git a/web/app/download/download.module.css b/web/app/download/download.module.css new file mode 100644 index 00000000..1fbe38f2 --- /dev/null +++ b/web/app/download/download.module.css @@ -0,0 +1,68 @@ +.hero { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + margin-bottom: 48px; +} + +.primary { + min-width: 260px; +} + +.choices { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.choice { + flex-direction: column; + gap: 2px; +} + +.choiceKind { + color: var(--fg-muted); + font-size: 0.8125rem; + font-weight: 500; +} + +.heroNote { + color: var(--fg-muted); + font-size: 0.9rem; + line-height: 1.6; +} + +.heroNote a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 3px; +} + +.file { + font-family: var(--font-geist-mono), monospace; + font-size: 0.8125rem; + word-break: break-all; +} + +.code { + font-size: 0.875em; + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: color-mix(in srgb, var(--fg) 4%, var(--bg)); +} + +@media (max-width: 640px) { + .hero { + align-items: stretch; + } + + .primary { + min-width: 0; + } + + .choices { + flex-direction: column; + } +} diff --git a/web/app/download/page.tsx b/web/app/download/page.tsx new file mode 100644 index 00000000..71788e33 --- /dev/null +++ b/web/app/download/page.tsx @@ -0,0 +1,111 @@ +import type { Metadata } from 'next'; + +import { GitHubStarsBadge } from '../../components/GitHubStars'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { DESKTOP_ASSETS, desktopAssetUrl } from '../../lib/desktop-downloads'; +import { absoluteUrl } from '../../lib/site'; +import legal from '../legal.module.css'; +import { DownloadButton } from './DownloadButton'; +import s from './download.module.css'; + +const description = + 'Download the Agent Relay desktop app for macOS, Linux, or Windows. It connects the coding agents on your machine to your team’s relay.'; + +export const metadata: Metadata = { + title: 'Download Agent Relay', + description, + alternates: { canonical: absoluteUrl('/download') }, + openGraph: { title: 'Download Agent Relay', description, url: absoluteUrl('/download') }, +}; + +export default function DownloadPage() { + return ( +
+ } /> + +
+

Download Agent Relay

+

+ The desktop app connects the coding agents on your machine to your team’s relay. Install it, sign in, + and your agents can reach each other. +

+ + + +
+

Let your agent install it

+

+ On macOS or Linux, give your coding agent one prompt and it downloads, verifies, and installs the app, + then signs you in and connects your workspace. You approve the sign-in; it does the rest. +

+

+ + Let your agent install it + +

+
+ +
+

All downloads

+

Every build is the latest release. Each has a SHA-256 checksum to verify the file you downloaded.

+
+ + + + + + + + + + + {DESKTOP_ASSETS.map((asset) => ( + + + + + + + ))} + +
PlatformFormatDownloadChecksum
{asset.label}{asset.kind} + + {asset.file} + + + + SHA-256 + +
+
+

+ Check a download with shasum -a 256 -c <file>.sha256 on macOS,{' '} + sha256sum -c <file>.sha256 on Linux, run in the folder that + holds both files, or compare the file’s{' '} + Get-FileHash with the checksum on Windows. +

+
+ +
+

Requirements

+
    +
  • + macOS 13 Ventura or later, on Apple silicon or Intel. +
  • +
  • + Linux with glibc 2.34 or later and GTK 4.6 or later, on x64 or ARM64. The + background service runs under systemd. +
  • +
  • + Windows on x64 or ARM64. Setup installs for your account only, without + administrator rights; the background service runs as a logon task in Task Scheduler. +
  • +
+
+
+ + +
+ ); +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 21b8901c..1c226f5a 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -21,6 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly', priority: 1.0, }, + { url: absoluteUrl('/download'), lastModified: now, changeFrequency: 'weekly', priority: 0.9 }, { url: absoluteUrl('/pear'), lastModified: now, diff --git a/web/components/SiteFooter.tsx b/web/components/SiteFooter.tsx index 39c34ba5..a31d3f9b 100644 --- a/web/components/SiteFooter.tsx +++ b/web/components/SiteFooter.tsx @@ -20,6 +20,7 @@ const footerColumns: FooterColumn[] = [ { title: 'Products', links: [ + { label: 'Desktop app', href: '/download' }, { label: 'Relay Cloud', href: 'https://agentrelay.com/cloud' }, { label: 'Flows', href: '/flows' }, { label: 'Skip', href: 'https://heyskip.dev' }, diff --git a/web/components/home/HomeSections.tsx b/web/components/home/HomeSections.tsx index 78d83b34..ce71d196 100644 --- a/web/components/home/HomeSections.tsx +++ b/web/components/home/HomeSections.tsx @@ -4,7 +4,7 @@ import { AgentSignup } from '../AgentSignup'; import { FadeIn } from '../FadeIn'; import { RelayRibbons } from '../RelayRibbons'; import landing from '../../app/landing.module.css'; -import { HOME_SIGNUP_HREF } from '../../lib/home-copy'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import { ScribbleUnderline } from './icons'; import { WhyRelay } from './WhyRelay'; import s from './home-sections.module.css'; @@ -50,9 +50,11 @@ export function FinalCta() {

Get your team on the relay

-

Install the desktop app and sign in. About 10 minutes to your first handoff.

+

+ Install the desktop app and sign in. About 10 minutes to your first handoff. +

- + Sign up for free diff --git a/web/components/home/home-sections.module.css b/web/components/home/home-sections.module.css index c1af3826..1d07f674 100644 --- a/web/components/home/home-sections.module.css +++ b/web/components/home/home-sections.module.css @@ -36,6 +36,13 @@ line-height: 1.55; } +.line a { + color: #74b8e2; + text-decoration: underline; + text-decoration-color: rgba(116, 184, 226, 0.4); + text-underline-offset: 3px; +} + .mark { position: relative; display: inline-block; diff --git a/web/content/docs/relayhistory/mcp.mdx b/web/content/docs/relayhistory/mcp.mdx index bca70520..5cedc2e6 100644 --- a/web/content/docs/relayhistory/mcp.mdx +++ b/web/content/docs/relayhistory/mcp.mdx @@ -81,7 +81,7 @@ args = ["-y", "ai-hist-mcp"] | `join_relay` | Make this session reachable by teammates and agents, with an optional public name and description. | | `leave_relay` | Take it off the relay again. | -The presence tools talk to the local Agent Relay desktop app over its socket (`AGENT_RELAY_SOCKET`, then `~/.agentworkforce/desktop/relay-socket`). If the app isn't running, they return an instruction to open it rather than failing the agent's turn. +The presence tools talk to the local [Agent Relay desktop app](/download) over its socket (`AGENT_RELAY_SOCKET`, then `~/.agentworkforce/desktop/relay-socket`). If the app isn't running, they return an instruction to open it rather than failing the agent's turn. ## Pages and cursors diff --git a/web/lib/agent-signup.ts b/web/lib/agent-signup.ts index 4917abcb..00993bb5 100644 --- a/web/lib/agent-signup.ts +++ b/web/lib/agent-signup.ts @@ -1,3 +1,4 @@ +import { DESKTOP_RELEASES } from './desktop-downloads'; import { SETUP_SKILLS, SETUP_SKILLS_SOURCE } from './generated/setup-skills'; export type AgentSignupProduct = 'teams' | 'flows'; @@ -194,8 +195,6 @@ function isLocalStack(site: string): boolean { return protocol === 'http:' && (hostname === 'localhost' || hostname === '127.0.0.1'); } -const DESKTOP_RELEASES = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download'; - /** * The skills name production. Another host substitutes its own exact origins; * the local stack (http://localhost or http://127.0.0.1) also substitutes, so the desktop commands run as diff --git a/web/lib/desktop-downloads.ts b/web/lib/desktop-downloads.ts new file mode 100644 index 00000000..32bba5e3 --- /dev/null +++ b/web/lib/desktop-downloads.ts @@ -0,0 +1,71 @@ +/** GitHub resolves `latest` server-side, so each asset name below is a stable link. */ +export const DESKTOP_RELEASES = 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download'; + +export type DesktopOs = 'macos' | 'linux' | 'windows'; +export type DesktopArch = 'arm64' | 'x64'; + +export type DesktopAsset = { + os: DesktopOs; + arch: DesktopArch; + file: string; + label: string; + /** The format's short name, shown beside the label. */ + kind: string; +}; + +export const DESKTOP_ASSETS: readonly DesktopAsset[] = [ + { os: 'macos', arch: 'arm64', file: 'AgentRelay-macOS-arm64.dmg', label: 'macOS, Apple silicon', kind: 'DMG' }, + { os: 'macos', arch: 'x64', file: 'AgentRelay-macOS-x64.dmg', label: 'macOS, Intel', kind: 'DMG' }, + { os: 'linux', arch: 'x64', file: 'AgentRelay-Linux-x64.deb', label: 'Linux, x64', kind: 'Debian package' }, + { os: 'linux', arch: 'arm64', file: 'AgentRelay-Linux-arm64.deb', label: 'Linux, ARM64', kind: 'Debian package' }, + { os: 'linux', arch: 'x64', file: 'AgentRelay-Linux-x64.tar.gz', label: 'Linux, x64', kind: 'tarball' }, + { os: 'linux', arch: 'arm64', file: 'AgentRelay-Linux-arm64.tar.gz', label: 'Linux, ARM64', kind: 'tarball' }, + { os: 'windows', arch: 'x64', file: 'AgentRelay-Windows-x64-Setup.exe', label: 'Windows, x64', kind: 'installer' }, + { os: 'windows', arch: 'arm64', file: 'AgentRelay-Windows-arm64-Setup.exe', label: 'Windows, ARM64', kind: 'installer' }, + { os: 'windows', arch: 'x64', file: 'AgentRelay-Windows-x64.zip', label: 'Windows, x64', kind: 'portable zip' }, + { os: 'windows', arch: 'arm64', file: 'AgentRelay-Windows-arm64.zip', label: 'Windows, ARM64', kind: 'portable zip' }, +]; + +export function desktopAssetUrl(file: string): string { + return `${DESKTOP_RELEASES}/${file}`; +} + +export type DesktopPlatform = { os: DesktopOs; arch: DesktopArch }; + +/** + * The visitor's platform from what the browser reports. `architecture` is the + * User-Agent Client Hints value (Chromium only). Safari and Firefox report every + * Mac as Intel, so a Mac without that hint is taken to be Apple silicon, which + * every Mac sold since 2023 is; the Intel build stays one click away. iPadOS + * Safari also reports a Mac, and only its touch points tell it apart. + */ +export function detectDesktopPlatform(userAgent: string, { architecture, touchPoints = 0 }: { architecture?: string; touchPoints?: number } = {}): DesktopPlatform | null { + const ua = userAgent.toLowerCase(); + const arm = architecture ? architecture.toLowerCase().startsWith('arm') : /\b(aarch64|arm64|armv8)\b/.test(ua); + if (/windows/.test(ua)) return { os: 'windows', arch: arm ? 'arm64' : 'x64' }; + if (/android|iphone|ipad|cros/.test(ua)) return null; + if (/macintosh|mac os x/.test(ua)) { + if (touchPoints > 1) return null; + return { os: 'macos', arch: architecture && !arm ? 'x64' : 'arm64' }; + } + if (/linux|x11/.test(ua)) return { os: 'linux', arch: arm ? 'arm64' : 'x64' }; + return null; +} + +/** The build a platform downloads first: the DMG on macOS, the Debian package on Linux, Setup on Windows. */ +export function primaryDesktopAsset(platform: DesktopPlatform): DesktopAsset { + return DESKTOP_ASSETS.find((asset) => asset.os === platform.os && asset.arch === platform.arch)!; +} + +/** + * The one other build offered beside the primary: the other architecture where + * detection can be wrong (Macs without Client Hints, Windows on Arm), and the + * tarball on Linux, whose architecture the user agent does report. + */ +export function alternateDesktopAsset(platform: DesktopPlatform): DesktopAsset { + if (platform.os === 'linux') { + const primary = primaryDesktopAsset(platform); + return DESKTOP_ASSETS.find((asset) => asset.os === 'linux' && asset.arch === platform.arch && asset !== primary)!; + } + return primaryDesktopAsset({ os: platform.os, arch: platform.arch === 'arm64' ? 'x64' : 'arm64' }); +} diff --git a/web/lib/test/desktop-downloads.test.ts b/web/lib/test/desktop-downloads.test.ts new file mode 100644 index 00000000..5c7ffb27 --- /dev/null +++ b/web/lib/test/desktop-downloads.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { alternateDesktopAsset, DESKTOP_ASSETS, desktopAssetUrl, detectDesktopPlatform, primaryDesktopAsset } from '../desktop-downloads'; + +const MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15'; +const LINUX_X64 = 'Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0'; +const LINUX_ARM = 'Mozilla/5.0 (X11; Linux aarch64; rv:131.0) Gecko/20100101 Firefox/131.0'; +const WINDOWS = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'; + +describe('detectDesktopPlatform', () => { + it('takes a Mac to be Apple silicon unless Client Hints report x86', () => { + expect(detectDesktopPlatform(MAC)).toEqual({ os: 'macos', arch: 'arm64' }); + expect(detectDesktopPlatform(MAC, { architecture: 'arm' })).toEqual({ os: 'macos', arch: 'arm64' }); + expect(detectDesktopPlatform(MAC, { architecture: 'x86' })).toEqual({ os: 'macos', arch: 'x64' }); + }); + + it('reads the Linux architecture from Client Hints or the user agent', () => { + expect(detectDesktopPlatform(LINUX_X64)).toEqual({ os: 'linux', arch: 'x64' }); + expect(detectDesktopPlatform(LINUX_ARM)).toEqual({ os: 'linux', arch: 'arm64' }); + expect(detectDesktopPlatform(LINUX_X64, { architecture: 'arm' })).toEqual({ os: 'linux', arch: 'arm64' }); + }); + + it('takes Windows to be x64 unless Client Hints report Arm', () => { + expect(detectDesktopPlatform(WINDOWS)).toEqual({ os: 'windows', arch: 'x64' }); + expect(detectDesktopPlatform(WINDOWS, { architecture: 'arm' })).toEqual({ os: 'windows', arch: 'arm64' }); + }); + + it('has no build for phones, tablets, or ChromeOS', () => { + expect(detectDesktopPlatform(MAC, { touchPoints: 5 })).toBeNull(); + expect(detectDesktopPlatform('Mozilla/5.0 (Linux; Android 14; Pixel 8)')).toBeNull(); + expect(detectDesktopPlatform('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull(); + expect(detectDesktopPlatform('Mozilla/5.0 (X11; CrOS x86_64 14541.0.0)')).toBeNull(); + }); +}); + +describe('desktop assets', () => { + it('leads with the DMG on macOS, the Debian package on Linux, and Setup on Windows', () => { + expect(primaryDesktopAsset({ os: 'macos', arch: 'x64' }).file).toBe('AgentRelay-macOS-x64.dmg'); + expect(primaryDesktopAsset({ os: 'linux', arch: 'arm64' }).file).toBe('AgentRelay-Linux-arm64.deb'); + expect(primaryDesktopAsset({ os: 'windows', arch: 'x64' }).file).toBe('AgentRelay-Windows-x64-Setup.exe'); + }); + + it('offers the other architecture on macOS and Windows, and the tarball on Linux', () => { + expect(alternateDesktopAsset({ os: 'macos', arch: 'arm64' }).file).toBe('AgentRelay-macOS-x64.dmg'); + expect(alternateDesktopAsset({ os: 'windows', arch: 'x64' }).file).toBe('AgentRelay-Windows-arm64-Setup.exe'); + expect(alternateDesktopAsset({ os: 'linux', arch: 'x64' }).file).toBe('AgentRelay-Linux-x64.tar.gz'); + }); + + it('lists every build from the latest release', () => { + expect(DESKTOP_ASSETS.map((asset) => asset.file)).toEqual([ + 'AgentRelay-macOS-arm64.dmg', + 'AgentRelay-macOS-x64.dmg', + 'AgentRelay-Linux-x64.deb', + 'AgentRelay-Linux-arm64.deb', + 'AgentRelay-Linux-x64.tar.gz', + 'AgentRelay-Linux-arm64.tar.gz', + 'AgentRelay-Windows-x64-Setup.exe', + 'AgentRelay-Windows-arm64-Setup.exe', + 'AgentRelay-Windows-x64.zip', + 'AgentRelay-Windows-arm64.zip', + ]); + expect(desktopAssetUrl('AgentRelay-macOS-arm64.dmg.sha256')).toBe( + 'https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download/AgentRelay-macOS-arm64.dmg.sha256', + ); + }); +}); From dbe604d96a86f6476b7125f028e0180557be2ce8 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 22 Oct 2026 23:41:42 -0700 Subject: [PATCH 02/12] Send every Log in to /login, tag Teams signup CTAs, and track home, login and signup page views Co-Authored-By: Claude Opus 5.5 --- web/app/pear/page.tsx | 4 ++-- web/app/teams/page.tsx | 9 ++++----- web/components/SiteNav.tsx | 5 ++--- web/components/home/SignupBand.tsx | 4 ++-- web/components/homepage/HomeHero.tsx | 5 +++-- web/components/homepage/Hosting.tsx | 4 ++-- web/components/homepage/NavActions.tsx | 4 ++-- web/components/homepage/Setup.tsx | 2 +- web/lib/home-copy.ts | 5 ----- web/lib/site-analytics.ts | 6 +++++- web/lib/teams-cloud.ts | 9 +++++++++ web/lib/test/site-analytics.test.ts | 10 ++++++++++ web/lib/test/teams-cloud.test.ts | 20 ++++++++++++++++++++ 13 files changed, 62 insertions(+), 25 deletions(-) create mode 100644 web/lib/test/teams-cloud.test.ts diff --git a/web/app/pear/page.tsx b/web/app/pear/page.tsx index ec38d9e2..b3039fbb 100644 --- a/web/app/pear/page.tsx +++ b/web/app/pear/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { GitHubStarsBadge } from '../../components/GitHubStars'; import { SiteFooter } from '../../components/SiteFooter'; import { SiteNav } from '../../components/SiteNav'; -import { teamsCloudUrl } from '../../lib/teams-cloud'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import { WorksWithEveryAgent } from '../../components/home/WorksWithEveryAgent'; import { ogImage } from '../../lib/og-meta'; import { absoluteUrl, SITE_NAME } from '../../lib/site'; @@ -578,7 +578,7 @@ export default function PearPage() {
Get started diff --git a/web/app/teams/page.tsx b/web/app/teams/page.tsx index cc8c6317..70322e23 100644 --- a/web/app/teams/page.tsx +++ b/web/app/teams/page.tsx @@ -13,7 +13,7 @@ import { SiteNav } from '../../components/SiteNav'; import { SiteFooter } from '../../components/SiteFooter'; import { TEAMS_OG_ALT, TEAMS_OG_IMAGE_PATH, ogImage } from '../../lib/og-meta'; import { absoluteUrl } from '../../lib/site'; -import { teamsCloudUrl } from '../../lib/teams-cloud'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import { AgentSignup } from '../../components/AgentSignup'; import home from '../landing.module.css'; import flows from '../flows/flows.module.css'; @@ -40,10 +40,9 @@ export const metadata: Metadata = { }; export default function TeamsPage() { - const signupHref = teamsCloudUrl('/api/auth/google/start?next=%2Fteams%2Fconnect'); const signupLabel = 'Sign up for free'; const setupLink = ( - + {signupLabel} ); @@ -64,7 +63,7 @@ export default function TeamsPage() { Share your coding agent sessions with your team, find overlapping work, and discover whats working for everyone.

- + {signupLabel} @@ -84,7 +83,7 @@ export default function TeamsPage() {

Your whole team's coding agents, under one roof

-

Work like you normally would. Agent sessions get shared automatically. Get a bird’s-eye view of all the work happening across the team.

+

Work like you normally would. Share the agent sessions you choose and get a bird’s-eye view of all the work happening across the team.

diff --git a/web/components/SiteNav.tsx b/web/components/SiteNav.tsx index b91f0453..14361cc3 100644 --- a/web/components/SiteNav.tsx +++ b/web/components/SiteNav.tsx @@ -7,7 +7,6 @@ import { usePathname } from 'next/navigation'; import s from './site-nav.module.css'; import { RELAY_MARK_PATHS } from '../lib/relay-mark'; -import { teamsCloudUrl } from '../lib/teams-cloud'; export function LogoIcon() { return ( @@ -97,7 +96,7 @@ export function SiteNav({ {!hideLinks && (
  • - + Log in
  • @@ -168,7 +167,7 @@ export function SiteNav({ }} > {!hideLinks && ( - setMenuOpen(false)}> + setMenuOpen(false)}> Log in )} diff --git a/web/components/home/SignupBand.tsx b/web/components/home/SignupBand.tsx index a7c6bfc0..d04f64f5 100644 --- a/web/components/home/SignupBand.tsx +++ b/web/components/home/SignupBand.tsx @@ -1,4 +1,4 @@ -import { teamsCloudUrl } from '../../lib/teams-cloud'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import s from '../../app/landing.module.css'; export function SignupBand() { @@ -14,7 +14,7 @@ export function SignupBand() {

diff --git a/web/components/homepage/HomeHero.tsx b/web/components/homepage/HomeHero.tsx index fc6e30be..6b834770 100644 --- a/web/components/homepage/HomeHero.tsx +++ b/web/components/homepage/HomeHero.tsx @@ -2,7 +2,8 @@ import type { CSSProperties } from 'react'; import { InvestorStrip } from '../InvestorStrip'; import { AgentIcons } from '../AgentSignup'; -import { HOME_HERO_DESCRIPTION, HOME_HERO_TITLE_LINES, HOME_SIGNUP_HREF } from '../../lib/home-copy'; +import { HOME_HERO_DESCRIPTION, HOME_HERO_TITLE_LINES } from '../../lib/home-copy'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import { HeroStream } from './HeroStream'; import s from '../../app/landing.module.css'; @@ -31,7 +32,7 @@ export function HomeHero() {

{HOME_HERO_DESCRIPTION}

- + Connect your agents for free

We host it

Sign up and go. Free to start.

-
+ Connect your agents for free diff --git a/web/components/homepage/NavActions.tsx b/web/components/homepage/NavActions.tsx index 545a659e..79fedc71 100644 --- a/web/components/homepage/NavActions.tsx +++ b/web/components/homepage/NavActions.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { AgentIcons } from '../AgentSignup'; -import { HOME_SIGNUP_HREF } from '../../lib/home-copy'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import landing from '../../app/landing.module.css'; import s from './nav-actions.module.css'; @@ -29,7 +29,7 @@ export function NavActions() { - Give the link to your agent, they’ll set it up. It’ll take 5 minutes, tops! + Give the link to your agent, they’ll set it up. About 10 minutes. ); } diff --git a/web/lib/home-copy.ts b/web/lib/home-copy.ts index 405e1bd4..ee6279cd 100644 --- a/web/lib/home-copy.ts +++ b/web/lib/home-copy.ts @@ -1,11 +1,6 @@ -import { teamsCloudUrl } from './teams-cloud'; - export const HOME_HERO_TITLE_LINES = ['Stop copy/pasting', 'between your agents'] as const; export const HOME_HERO_TITLE = HOME_HERO_TITLE_LINES.join(' '); export const HOME_HERO_DESCRIPTION = 'Your agents should talk to each other, just not through you. Connect your agents and your teammates’ agents so they can coordinate directly, without the copy-paste.'; - -/** Google sign-in that lands on the connect-your-agents page. */ -export const HOME_SIGNUP_HREF = teamsCloudUrl('/api/auth/google/start?next=%2Fteams%2Fconnect'); diff --git a/web/lib/site-analytics.ts b/web/lib/site-analytics.ts index 1ae99e3b..39abb20a 100644 --- a/web/lib/site-analytics.ts +++ b/web/lib/site-analytics.ts @@ -1,4 +1,7 @@ const TRACKED_ROUTE_PREFIXES = [ + { prefix: '/login', pageGroup: 'login' }, + { prefix: '/signup', pageGroup: 'signup' }, + { prefix: '/download', pageGroup: 'download' }, { prefix: '/flows', pageGroup: 'flows' }, { prefix: '/teams', pageGroup: 'teams' }, { prefix: '/docs', pageGroup: 'docs' }, @@ -39,7 +42,7 @@ export function dropAgentDashboardAnalytics(event: T | null): T | null { } export type WebsiteAnalyticsPage = { - pageGroup: (typeof TRACKED_ROUTE_PREFIXES)[number]['pageGroup']; + pageGroup: 'home' | (typeof TRACKED_ROUTE_PREFIXES)[number]['pageGroup']; pathname: string; }; @@ -51,6 +54,7 @@ export function getWebsiteAnalyticsPage(pathname: string | null | undefined): We if (!pathname) return null; if (isAgentDashboardPath(pathname)) return null; if (EXCLUDED_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null; + if (pathname === '/') return { pageGroup: 'home', pathname }; const match = TRACKED_ROUTE_PREFIXES.find(({ prefix }) => matchesPrefix(pathname, prefix)); if (!match) return null; diff --git a/web/lib/teams-cloud.ts b/web/lib/teams-cloud.ts index 473cd88b..a8711ad6 100644 --- a/web/lib/teams-cloud.ts +++ b/web/lib/teams-cloud.ts @@ -1,3 +1,12 @@ export function teamsCloudUrl(path: string): string { return `${(process.env.NEXT_PUBLIC_CLOUD_URL || '/cloud').replace(/\/$/, '')}${path}`; } + +/** + * Google sign-up that lands on the connect-your-agents page. `placement` names + * the CTA in `utm_content`, so click analytics can tell the buttons apart. + */ +export function teamsGoogleAuthHref(placement: string): string { + const params = new URLSearchParams({ source: 'teams', next: '/teams/connect', utm_content: placement }); + return teamsCloudUrl(`/api/auth/google/start?${params.toString()}`); +} diff --git a/web/lib/test/site-analytics.test.ts b/web/lib/test/site-analytics.test.ts index 56bb5612..e7c0a9b3 100644 --- a/web/lib/test/site-analytics.test.ts +++ b/web/lib/test/site-analytics.test.ts @@ -8,6 +8,16 @@ describe('getWebsiteAnalyticsPage', () => { expect(getWebsiteAnalyticsPage(pathname)).toEqual({ pageGroup: 'flows', pathname }); } }); + it('tracks the homepage, sign-in, agent signup, and download pages', () => { + expect(getWebsiteAnalyticsPage('/')).toEqual({ pageGroup: 'home', pathname: '/' }); + expect(getWebsiteAnalyticsPage('/login')).toEqual({ pageGroup: 'login', pathname: '/login' }); + for (const pathname of ['/signup/teams', '/signup/flows']) { + expect(getWebsiteAnalyticsPage(pathname)).toEqual({ pageGroup: 'signup', pathname }); + } + expect(getWebsiteAnalyticsPage('/download')).toEqual({ pageGroup: 'download', pathname: '/download' }); + expect(getWebsiteAnalyticsPage('/loginx')).toBeNull(); + }); + it('tracks docs routes', () => { expect(getWebsiteAnalyticsPage('/docs/quickstart')).toEqual({ pageGroup: 'docs', diff --git a/web/lib/test/teams-cloud.test.ts b/web/lib/test/teams-cloud.test.ts new file mode 100644 index 00000000..dc047749 --- /dev/null +++ b/web/lib/test/teams-cloud.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { teamsGoogleAuthHref } from '../teams-cloud'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('teamsGoogleAuthHref', () => { + it('starts Google auth for Teams, returns to connect, and names the CTA placement', () => { + vi.stubEnv('NEXT_PUBLIC_CLOUD_URL', ''); + expect(teamsGoogleAuthHref('home_hero')).toBe( + '/cloud/api/auth/google/start?source=teams&next=%2Fteams%2Fconnect&utm_content=home_hero', + ); + }); + + it('uses the configured Cloud origin without a duplicate slash', () => { + vi.stubEnv('NEXT_PUBLIC_CLOUD_URL', 'https://cloud.example.test/cloud/'); + expect(teamsGoogleAuthHref('teams_nav')).toBe( + 'https://cloud.example.test/cloud/api/auth/google/start?source=teams&next=%2Fteams%2Fconnect&utm_content=teams_nav', + ); + }); +}); From e09f212b7eaa8a93a98ffa197bd24bdb33f2751e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 22 Oct 2026 23:41:42 -0700 Subject: [PATCH 03/12] Agent signup: visible restart when setup fails, referrer-aware Back, Linux in the hint, chosen-sessions sharing copy Co-Authored-By: Claude Opus 5.5 --- web/components/AgentSignupJourney.tsx | 19 ++++++++-- .../agent-signup-journey.module.css | 2 +- web/e2e/agent-signup.spec.ts | 36 +++++++++++++++++++ web/lib/agent-signup-progress.ts | 2 +- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/web/components/AgentSignupJourney.tsx b/web/components/AgentSignupJourney.tsx index 1088fac7..0fe45248 100644 --- a/web/components/AgentSignupJourney.tsx +++ b/web/components/AgentSignupJourney.tsx @@ -60,6 +60,17 @@ async function startSession(product: AgentSignupProduct, analytics?: SignupAnaly return { progress: session, token: session.writeToken }; } +/** The same-origin page that linked here, so Back returns to it rather than a fixed landing page. */ +function previousSitePage(location: Location): string | null { + try { + const referrer = new URL(document.referrer); + if (referrer.origin !== location.origin || referrer.pathname === location.pathname) return null; + return `${referrer.pathname}${referrer.search}${referrer.hash}`; + } catch { + return null; + } +} + export function AgentSignupJourney({ product }: { product: AgentSignupProduct }) { const analytics = useSignupAnalytics(product); const viewed = useRef(false); @@ -69,6 +80,7 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct }) const [progress, setProgress] = useState(); const [token, setToken] = useState(); const [origin, setOrigin] = useState(''); + const [backHref, setBackHref] = useState(`/${product}`); const [error, setError] = useState(''); const [copyMessage, setCopyMessage] = useState(''); const [expired, setExpired] = useState(false); @@ -92,6 +104,7 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct }) const prompt = progress && token ? trackedSignupPrompt(product, origin, endpoint, { id: progress.id, writeToken: token }) : ''; useEffect(() => { setAnswerValue(''); setAnswerError(''); }, [inputRequest?.id]); + useEffect(() => { setBackHref(previousSitePage(window.location) ?? `/${product}`); }, [product]); latest.current = { step: active, owner: Boolean(token) }; useEffect(() => { @@ -206,7 +219,7 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct }) return (
- +

{heading}

@@ -220,8 +233,9 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct })
)} {complete ? { if (token) analytics.track('dashboard_opened', active); }} className={s.primary} href={teamsCloudUrl(product === 'teams' ? '/dashboard/sessions' : '/dashboard')}>Open {product === 'teams' ? 'your workspace' : 'dashboard'} + : expired || failed ? : !active && !expired && !answeredInput && inputRequest?.status !== 'pending' ? : null} -

{complete ? '' : expired ? '' : notice ? 'No action is required to keep this draft saved.' : inputRequest?.status === 'pending' ? 'Your answer goes straight to your agent.' : answeredInput ? 'Your agent is processing your answer.' : active ? (paused ? 'Your agent will continue after you approve.' : 'You can leave this page open.') : copyMessage || (progress && !token ? 'Watching this session. The prompt is in the original browser tab.' : product === 'teams' ? 'Paste into a coding agent on your Mac.' : 'Paste into a coding agent with terminal access.')}

+

{complete ? '' : expired ? '' : notice ? 'No action is required to keep this draft saved.' : inputRequest?.status === 'pending' ? 'Your answer goes straight to your agent.' : answeredInput ? 'Your agent is processing your answer.' : active ? (paused ? 'Your agent will continue after you approve.' : 'You can leave this page open.') : copyMessage || (progress && !token ? 'Watching this session. The prompt is in the original browser tab.' : product === 'teams' ? 'Paste into a coding agent on your Mac or Linux machine.' : 'Paste into a coding agent with terminal access.')}

{inputRequest && !expired && !complete && (inputRequest.status === 'pending' || answeredInput) &&
{notice ? <>

INACTIVE PREVIEW

@@ -275,7 +289,6 @@ export function AgentSignupJourney({ product }: { product: AgentSignupProduct })
{showPrompt && prompt &&