diff --git a/.claude/skills/writing-relayflows/SKILL.md b/.claude/skills/writing-relayflows/SKILL.md index c3ae3a18..06ab6b55 100644 --- a/.claude/skills/writing-relayflows/SKILL.md +++ b/.claude/skills/writing-relayflows/SKILL.md @@ -20,6 +20,43 @@ Relayflows turns a coding-agent task into steps a journal can inspect, verify, a - Debugging a `REFUSED [...]` message from `flows check` or `flows run`. - Deciding between running a flow locally, deploying it to listen for tickets, or scheduling it on a cron. +## Inputs and hosted authoring + +The flow body receives **two arguments**: `(f: Ctx, inputs: Input)`. `f` supplies journaled operations; it has no `inputs` property. Declare a concrete input type and read values from the second argument. Do not use `as any` or `@ts-ignore` to make an invented SDK property compile. + +```ts +import { flow } from '@relayflows/surface'; + +type Input = { task: string }; +export default flow('summarize', async (f, inputs) => { + if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs) || + typeof inputs.task !== 'string') return f.done('declined'); + const task = inputs.task.trim(); + if (!task) return f.done('declined'); + const summary = await f.llm`Summarize this task: ${task}`; + console.log(summary); + f.done('success'); +}); +``` + +The `Input` annotation does not validate an invocation at runtime. Validate the +input object and field types before calling string methods or recording effects. +The example declines missing, non-string, empty, and whitespace-only tasks. +Run `node --test scripts/validate-input-example.test.mjs` from this skill directory +to exercise the exact documented callback with inert model and completion stubs. + +For a hosted flow, use the host-supplied input schema, trigger configuration, and capabilities. A provider's raw webhook is not necessarily the flow's input: Cloud projects events into normalized records. Manual/API inputs and provider-event inputs are different contracts. If the exact event or required field is unspecified, ask for clarification instead of inventing a payload or falling back to an empty URL/undefined identifier. Keep the chosen event family through deployment: a PR reviewer needs a pull-request source, not an issue source. + +For provider reads, prefer connected, documented readers or mounted records. A repository checkout can supply a diff when the required refs are present. A public `diff_url` and unauthenticated `curl` are not a substitute for an authenticated integration, especially for private repositories. Shell commands remain appropriate for deterministic work; do not interpolate untrusted event fields into shell commands. Validate required inputs before recording effects. + +Use the published helper signatures. For example, GitHub comments take a target object and body, **two arguments**: + +```ts +await f.github.comment({ owner: pr.owner, repo: pr.repo, number: pr.number }, reviewBody); +``` + +Before accepting generated source, check its SDK calls against declarations and its inputs against the selected host event. Compile-only checks cannot prove a listener supplies the expected input. Exercise the selected event with a representative fixture, including missing required input, without posting to a real provider. Correct contract errors before activation. + ## The ladder Three step verbs, one per rung (`@relayflows/sdk`'s `StepType = 'deterministic' | 'llm' | 'agent'`): diff --git a/.claude/skills/writing-relayflows/scripts/validate-input-example.test.mjs b/.claude/skills/writing-relayflows/scripts/validate-input-example.test.mjs new file mode 100644 index 00000000..732747db --- /dev/null +++ b/.claude/skills/writing-relayflows/scripts/validate-input-example.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { runInNewContext } from 'node:vm'; + +// Execute the actual documentation body so a stale copied implementation cannot +// hide a regression. Neither the SDK nor a provider/model is invoked. +const skill = readFileSync(new URL('../SKILL.md', import.meta.url), 'utf8'); +const body = /export default flow\('summarize', async \(f, inputs\) => \{([\s\S]*?)^\}\);/mu.exec(skill)?.[1]; +assert.ok(body, 'The documented summarize Flow callback must exist'); +const invoke = runInNewContext(`(async (f, inputs) => {${body}})`, { + console: { log() {} }, +}); + +async function exercise(inputs) { + const completions = []; + const modelCalls = []; + await invoke({ + done(reason) { completions.push(reason); }, + async llm(strings, ...values) { + modelCalls.push({ strings: Array.from(strings), values }); + return 'A summary'; + }, + }, inputs); + return { completions, modelCalls }; +} + +const invalidCases = [ + ['missing inputs', undefined], + ['null inputs', null], + ['boolean inputs', false], + ['number inputs', 42], + ['string inputs', 'task'], + ['array inputs', Object.assign([], { task: 'task' })], + ['missing task', {}], + ['undefined task', { task: undefined }], + ['null task', { task: null }], + ['boolean task', { task: false }], + ['number task', { task: 42 }], + ['array task', { task: ['task'] }], + ['object task', { task: { trim() { throw new Error('Must not call trim'); } } }], + ['empty task', { task: '' }], + ['whitespace task', { task: ' \t\n ' }], +]; + +for (const [name, inputs] of invalidCases) { + test(`${name} declines without calling the model`, async () => { + assert.deepEqual(await exercise(inputs), { completions: ['declined'], modelCalls: [] }); + }); +} + +for (const task of ['Review the change', ' \tReview the change\n ']) { + test(`valid task ${JSON.stringify(task)} invokes the model once and succeeds`, async () => { + const result = await exercise({ task }); + assert.deepEqual(result.completions, ['success']); + assert.equal(result.modelCalls.length, 1); + assert.deepEqual(result.modelCalls[0].values, [task.trim()]); + }); +} diff --git a/.gitignore b/.gitignore index 271c4ad1..6d9cf072 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,6 @@ web/.dev.vars **/tsconfig.tsbuildinfo # Runtime data -web/waitlist.json .agentworkforce/ # Env @@ -36,9 +35,6 @@ web/waitlist.json # Palate website-builder skill telemetry, written at the repo root during agent sessions build-manifest.json -# Native probe builds are staged locally, not committed with the website. -web/public/downloads/agent-relay-probe/ - # Local Claude Code state. Shared skills and handoffs are committed explicitly; # settings.json is a per-developer permission allowlist and must not be. .claude/settings.json diff --git a/prpm.lock b/prpm.lock index 0c289b1d..243c90c9 100644 --- a/prpm.lock +++ b/prpm.lock @@ -23,14 +23,14 @@ "installedPath": ".claude/skills/setting-up-agent-relay-sessions/SKILL.md" }, "@agent-relay/writing-relayflows#claude": { - "version": "1.6.2", - "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fwriting-relayflows/1.6.2.tar.gz", - "integrity": "sha256-1b57a97f93f65ef4fc4b2eddadafdedce2d0e7e917bc8efe3d771e8c5d6a431b", + "version": "1.6.4", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fwriting-relayflows/1.6.4.tar.gz", + "integrity": "sha256-716c47c2780aa2ee07dede1d5a2a1f574224f108ad1dfc9b96cc5a054ce66874", "format": "claude", "subtype": "skill", "sourceFormat": "claude", "sourceSubtype": "skill", - "installedPath": ".claude/skills/writing-relayflows/SKILL.md" + "installedPath": ".claude/skills/writing-relayflows" }, "@agent-relay/signing-in-to-agent-relay-cloud#claude": { "version": "0.1.0", @@ -53,5 +53,5 @@ "installedPath": ".claude/skills/setting-up-agent-relay-flows/SKILL.md" } }, - "generated": "2026-10-15T19:24:42.594Z" + "generated": "2026-10-23T06:15:07.751Z" } \ No newline at end of file diff --git a/router/index.ts b/router/index.ts index a7e83ce6..5e992c3e 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 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/scripts/stage-probe.mjs b/scripts/stage-probe.mjs deleted file mode 100644 index 24d255c7..00000000 --- a/scripts/stage-probe.mjs +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node -import { copyFile, mkdir, readFile, writeFile, chmod } from 'node:fs/promises'; -import { createHash } from 'node:crypto'; -import { resolve, dirname, isAbsolute } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parseArgs } from 'node:util'; -const { values } = parseArgs({ options: { binary: { type: 'string' }, platform: { type: 'string' } } }); -if (!values.binary || !isAbsolute(values.binary) || !/^(darwin|linux)-(arm64|x64)$/.test(values.platform ?? '')) { - throw new Error('Use --binary /absolute/native-binary --platform darwin-arm64 (or supported linux/macOS variant).'); -} -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const destination = resolve(root, 'web/public/downloads/agent-relay-probe', values.platform); -const bytes = await readFile(values.binary); -await mkdir(destination, { recursive: true }); -await copyFile(values.binary, resolve(destination, 'agent-relay-probe')); -await chmod(resolve(destination, 'agent-relay-probe'), 0o755); -await writeFile(resolve(destination, 'agent-relay-probe.sha256'), createHash('sha256').update(bytes).digest('hex') + ' agent-relay-probe\n'); -console.log('Staged native probe and SHA-256 for local website testing.'); diff --git a/web/app/download/DownloadButton.tsx b/web/app/download/DownloadButton.tsx new file mode 100644 index 00000000..921eff57 --- /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..10b8b52f --- /dev/null +++ b/web/app/download/download.module.css @@ -0,0 +1,224 @@ +/* The download page follows the open source page: a plain navy hero with the + one download for this machine, the homepage's wave break, then one band + with a ruled column per platform. */ + +/* ---- Hero ---- */ + +.hero { + max-width: 1200px; + margin: 0 auto; + padding: 144px 40px 96px; +} + +.heroTitle { + margin: 0; + color: #fff; + font-family: var(--font-heading), sans-serif; + font-size: clamp(2.75rem, 6vw, 4.5rem); + font-weight: 600; + letter-spacing: -0.035em; + line-height: 1; +} + +.heroLine { + max-width: 46ch; + margin: 24px 0 0; + color: var(--fg-muted); + font-size: 1.2rem; + line-height: 1.55; +} + +.actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; + margin-top: 40px; +} + +.primary { + min-height: 52px; + padding: 14px 28px; + font-size: 1rem; +} + +.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, +.agentInstall { + margin: 0; + color: var(--fg-faint); + font-size: 0.9rem; + line-height: 1.6; +} + +.agentInstall { + margin-top: 28px; + color: var(--fg-muted); +} + +.heroNote a, +.agentInstall a { + color: #74b8e2; + text-decoration: none; +} + +.heroNote a:hover, +.agentInstall a:hover { + text-decoration: underline; + text-underline-offset: 3px; +} + +/* ---- All downloads ---- */ + +.catalog { + position: relative; + isolation: isolate; + max-width: 1200px; + margin: 0 auto; + padding: 88px 40px 120px; + scroll-margin-top: 24px; +} + +/* The band steps down a tone from the hero, full bleed. */ +.catalog::before { + position: absolute; + inset: 0 calc(50% - 50vw); + z-index: -1; + background: #0b1c2a; + border-top: 1px solid rgba(116, 184, 226, 0.1); + content: ''; +} + +.catalogTitle { + margin: 0 0 40px; + color: #fff; + font-family: var(--font-heading), sans-serif; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.platforms { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 48px; +} + +.platformName { + margin: 0 0 16px; + color: #fff; + font-family: var(--font-heading), sans-serif; + font-size: 1.08rem; + font-weight: 600; +} + +.builds { + margin: 0; + padding: 0; + border-top: 1px solid rgba(116, 184, 226, 0.16); + list-style: none; +} + +.build { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid rgba(116, 184, 226, 0.16); +} + +.buildLink { + display: flex; + flex: 1; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 14px 0; + color: #fff; + text-decoration: none; + transition: color 0.15s; +} + +.buildLink:hover { + color: #74b8e2; +} + +.buildArch { + font-size: 0.95rem; + font-weight: 500; +} + +.buildKind { + color: var(--fg-muted); + font-size: 0.85rem; +} + +.checksum { + color: var(--fg-faint); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 0.75rem; + text-decoration: none; +} + +.checksum:hover { + color: #74b8e2; +} + +.requirements { + margin: 16px 0 0; + color: #a8b8c8; + font-size: 0.9rem; + line-height: 1.55; +} + +.verify { + margin: 10px 0 0; + color: var(--fg-faint); + font-size: 0.8rem; +} + +.verify code { + font-family: var(--font-geist-mono), ui-monospace, monospace; + color: var(--fg-muted); +} + +@media (max-width: 900px) { + .platforms { + grid-template-columns: minmax(0, 1fr); + gap: 40px; + } +} + +@media (max-width: 640px) { + .hero { + padding: 112px 20px 72px; + } + + .catalog { + padding: 64px 20px 96px; + } + + .actions { + align-items: stretch; + } + + .choices { + flex-direction: column; + } +} diff --git a/web/app/download/page.tsx b/web/app/download/page.tsx new file mode 100644 index 00000000..b0e71604 --- /dev/null +++ b/web/app/download/page.tsx @@ -0,0 +1,125 @@ +import type { Metadata } from 'next'; + +import { WaveDivider } from '../../components/home/icons'; +import sections from '../../components/home/home-sections.module.css'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { DESKTOP_ASSETS, type DesktopAsset, type DesktopOs, desktopAssetUrl } from '../../lib/desktop-downloads'; +import { absoluteUrl } from '../../lib/site'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; +import s from '../landing.module.css'; +import { DownloadButton } from './DownloadButton'; +import d 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') }, +}; + +const PLATFORMS: readonly { os: DesktopOs; name: string; requirements: string; verify: string }[] = [ + { + os: 'macos', + name: 'macOS', + requirements: 'macOS 13 Ventura or later.', + verify: 'shasum -a 256 -c .sha256', + }, + { + os: 'linux', + name: 'Linux', + requirements: 'glibc 2.34+ and GTK 4.6+. The background service runs under systemd.', + verify: 'sha256sum -c .sha256', + }, + { + os: 'windows', + name: 'Windows', + requirements: + 'Installs for your account without admin rights. The build is unsigned, so SmartScreen may ask you to confirm: choose More info › Run anyway.', + verify: 'Get-FileHash ', + }, +]; + +function archName(asset: DesktopAsset): string { + if (asset.os === 'macos') return asset.arch === 'arm64' ? 'Apple silicon' : 'Intel'; + return asset.arch === 'arm64' ? 'ARM64' : 'x64'; +} + +export default function DownloadPage() { + return ( +
+ + Skip to content + + + + + Log in + + + Sign up + + + } + /> + +
+
+

Download Agent Relay

+

The desktop app connects the coding agents on your machine to your team’s relay.

+ +

+ On macOS or Linux, your coding agent can install it for you. Get the setup prompt +

+
+ +
+ +
+ +
+

+ All downloads +

+
+ {PLATFORMS.map((platform) => ( +
+

{platform.name}

+ +

{platform.requirements}

+

+ Verify {platform.verify} +

+
+ ))} +
+
+
+ + +
+ ); +} diff --git a/web/app/open-source/page.tsx b/web/app/open-source/page.tsx index b189f43f..14832699 100644 --- a/web/app/open-source/page.tsx +++ b/web/app/open-source/page.tsx @@ -6,10 +6,10 @@ import sections from '../../components/home/home-sections.module.css'; import { LiveStarsProvider, StarCount, TotalStars } from '../../components/open-source/LiveStars'; import { SiteFooter } from '../../components/SiteFooter'; import { SiteNav } from '../../components/SiteNav'; -import { HOME_SIGNUP_HREF } from '../../lib/home-copy'; import { DEFAULT_OG_IMAGE_PATH, ogImage } from '../../lib/og-meta'; import { fetchOrgRepoStats, GITHUB_ORG, GITHUB_ORG_URL, OSS_CATEGORIES, type OssRepo } from '../../lib/open-source'; import { absoluteUrl, SITE_NAME } from '../../lib/site'; +import { teamsGoogleAuthHref } from '../../lib/teams-cloud'; import s from '../landing.module.css'; import o from './open-source.module.css'; @@ -99,7 +99,7 @@ export default async function OpenSourcePage() { Log in - + Sign up diff --git a/web/app/pear/download/route.ts b/web/app/pear/download/route.ts index 99a520ee..5fc872cc 100644 --- a/web/app/pear/download/route.ts +++ b/web/app/pear/download/route.ts @@ -5,7 +5,7 @@ // (`pear-arm64.dmg`), so GitHub's `releases/latest/download/` URL — which // resolves "latest" server-side — is a constant target: // -// wget --content-disposition https://origin.agentrelay.net/pear/download +// wget --content-disposition https://agentrelay.com/pear/download const LATEST_DMG = 'https://github.com/AgentWorkforce/pear/releases/latest/download/pear-arm64.dmg'; export function GET() { 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/sitemap.ts b/web/app/sitemap.ts index 0a061f79..5d23e82f 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('/open-source'), lastModified: now, 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/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 &&