feat(website): replace the Cloud page with Paseo Hub

The Cloud page pitched a hosted multi-user product and collected emails
through a signup form. The Hub is a self-hosted service in private beta
and the only way in is a Discord DM, so the signup server function and
its webhook are deleted rather than repointed. /cloud now 301s to /hub
from the worker entry, which already owns the site's permanent path
redirects, so old links and search results keep working.

The homepage also drops split panels, service proxy, voice, and keyboard
shortcuts. They had turned the page into a feature list; it should show
what Paseo enables at a high level.

Brand and provider icons move into shared modules so the Hub page, the
site header, and the landing page each use one copy instead of three.
This commit is contained in:
Mohamed Boudra
2026-08-03 14:08:59 +02:00
parent 4406bf588e
commit ed4fc08f78
12 changed files with 680 additions and 797 deletions
-216
View File
@@ -1,216 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useState, type FormEvent } from "react";
import { submitCloudSignup, type CloudSignupInput } from "~/cloud-signup";
import { FAQItem } from "~/components/faq-item";
import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/cloud")({
head: () =>
pageMeta(
"Paseo Cloud - Design Partners",
"Run Paseo across machines, with a team, or inside a company. We're onboarding design partners for the hosted, multi-machine, multi-user version of Paseo.",
"/cloud",
),
component: Cloud,
});
const INPUT_CLASS =
"block w-full rounded-md bg-white/5 border border-white/10 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/30 transition-colors";
type Status = "idle" | "submitting" | "success" | "error";
function Cloud() {
return (
<SiteShell width="default">
<h1 className="text-3xl font-medium mb-3">Paseo Cloud</h1>
<p className="text-white/70 leading-relaxed mb-10">
For using Paseo across machines, with a team, or inside a company. Looking for design
partners.
</p>
<div className="space-y-20">
<SignupForm />
<FaqSection />
</div>
</SiteShell>
);
}
function SignupForm() {
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setStatus("submitting");
setError(null);
const form = new FormData(event.currentTarget);
const data: CloudSignupInput = {
email: String(form.get("email") ?? ""),
name: form.get("name") ? String(form.get("name")) : undefined,
company: form.get("company") ? String(form.get("company")) : undefined,
role: form.get("role") ? String(form.get("role")) : undefined,
message: String(form.get("message") ?? ""),
honeypot: form.get("website") ? String(form.get("website")) : "",
};
try {
await submitCloudSignup({ data });
setStatus("success");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Something went wrong.");
}
}, []);
if (status === "success") {
return (
<section className="space-y-3">
<p className="text-white/70">
Got it. I&apos;ll be in touch. If you don&apos;t hear back within a week, ping me on{" "}
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
Discord
</a>
.
</p>
</section>
);
}
const submitting = status === "submitting";
return (
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<Field label="Email" required>
<input type="email" name="email" required autoComplete="email" className={INPUT_CLASS} />
</Field>
<Field label="Name">
<input type="text" name="name" autoComplete="name" className={INPUT_CLASS} />
</Field>
<Field label="Company">
<input type="text" name="company" autoComplete="organization" className={INPUT_CLASS} />
</Field>
<Field label="Role">
<input
type="text"
name="role"
autoComplete="organization-title"
className={INPUT_CLASS}
/>
</Field>
</div>
<Field label="Message" required>
<textarea
name="message"
required
rows={5}
placeholder="A bit about you and what you'd want from Paseo Cloud."
className={`${INPUT_CLASS} resize-y`}
/>
</Field>
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute left-[-9999px] w-px h-px opacity-0"
/>
{error && (
<p className="text-sm text-red-400">
{error === "webhook not configured"
? "The form isn't wired up yet. Try Discord for now."
: "Something went wrong. Try again or DM me on Discord."}
</p>
)}
<div className="flex items-center gap-4 pt-4">
<button
type="submit"
disabled={submitting}
className="rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{submitting ? "Sending…" : "Send"}
</button>
<p className="text-sm text-white/50">
Or{" "}
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
DM me on Discord
</a>
.
</p>
</div>
</form>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="block text-sm font-medium text-white/80 pb-2">
{label}
{required && <span className="text-white/40"> *</span>}
</span>
{children}
</label>
);
}
function FaqSection() {
return (
<section className="space-y-6">
<h2 className="text-3xl font-medium">FAQ</h2>
<div className="space-y-6">
<FAQItem question="What is Paseo Cloud?">
An optional layer on top of Paseo for running daemons across machines, syncing config
between them, and using Paseo with a team or company. Think shared runners, permissions,
audit, managed daemons, and org controls.
</FAQItem>
<FAQItem question="Will Paseo stay free and open source?">
Yes. The whole stack stays free and open source: app, daemon, CLI, protocols, and the
Cloud control plane. Managed Cloud is the optional paid layer for people who don&apos;t
want to host it themselves.
</FAQItem>
<FAQItem question="Self-hosted or managed?">
Both. The control plane will live in the Paseo monorepo so you can host it yourself.
Managed is for people and teams who don&apos;t want to.
</FAQItem>
<FAQItem question="Does my code go through Paseo?">
No. Daemons run on your machines and talk to agent providers directly. Cloud handles
registration, config sync, permissions, and orchestration. Code and model traffic stay on
your machines.
</FAQItem>
<FAQItem question="When will it be available?">
Early access for design partners now. No public date. The app and daemon come first.
</FAQItem>
<FAQItem question="How does pricing work?">
Not set yet. Design partners help shape it.
</FAQItem>
</div>
</section>
);
}
+449
View File
@@ -0,0 +1,449 @@
import { createFileRoute } from "@tanstack/react-router";
import type { ReactNode } from "react";
import {
ClaudeCodeIcon,
CodexIcon,
CursorIcon,
OpenCodeIcon,
PiIcon,
} from "~/components/agent-icons";
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { AGENT_PAGES } from "~/data/agent-pages";
import { FAQItem } from "~/components/faq-item";
import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/hub")({
head: () =>
pageMeta(
"Paseo Hub - GitHub, Slack, and Discord triggers",
"An optional service above your Paseo daemons. Mention a bot in a GitHub issue and an agent runs on your own machine and opens a PR. Self-hosted first, private beta.",
"/hub",
),
component: Hub,
});
const DISCORD_INVITE_URL = "https://discord.gg/jz8T2uahpH";
const LINK_CLASS = "underline hover:text-white/80";
function Hub() {
return (
<SiteShell width="default">
<h1 className="text-3xl font-medium tracking-tight mb-4">Paseo Hub</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
An optional service that sits above your daemons and gives them extra capabilities.
</p>
<p className="text-white/40 text-sm mt-3">Private beta. Self-hosted first.</p>
<div className="space-y-20 mt-16">
<Triggers />
<Agents />
<Shape />
<Access />
<FaqSection />
</div>
</SiteShell>
);
}
const TRIGGER_SURFACES = [
{ name: "GitHub", icon: <GitHubIcon className="h-6 w-6" />, body: "Issues, PRs, and reviews." },
{ name: "Slack", icon: <SlackIcon className="h-6 w-6" />, body: "Channels and threads." },
{ name: "Discord", icon: <DiscordIcon className="h-6 w-6" />, body: "Channels and threads." },
];
function Triggers() {
return (
<section className="space-y-6">
<h2 className="text-xl font-medium">Triggers, running on your machines</h2>
<p className="text-white/70 leading-relaxed max-w-2xl">
Mention the bot in GitHub, Slack, or Discord and an agent starts on your daemon.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{TRIGGER_SURFACES.map((surface) => (
<div
key={surface.name}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"
>
<span className="text-white/80">{surface.icon}</span>
<span className="min-w-0">
<span className="block font-medium">{surface.name}</span>
<span className="block text-xs text-muted-foreground">{surface.body}</span>
</span>
</div>
))}
</div>
<DiscordDemo />
</section>
);
}
const DEMO_ISSUES = [
{ id: "#2811", title: "Terminal output drops under load" },
{ id: "#2804", title: "Relay reconnect loop on Android after sleep" },
{ id: "#2799", title: "Worktree cleanup leaves stale branches" },
];
function DiscordDemo() {
return (
<figure className="space-y-3">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-2 border-b border-white/10 bg-white/[0.03] px-5 py-3">
<DiscordIcon className="h-4 w-4 text-white/40" />
<span className="font-mono text-xs text-white/50">#engineering</span>
</div>
<div className="space-y-5 p-5">
<DemoMessage author="moboudra" time="09:41">
<p>
<Mention /> show me the latest P0 issues
</p>
</DemoMessage>
<DemoMessage author="paseo-bot" time="09:41" bot>
<p>Three P0 issues are open:</p>
<ul className="space-y-1">
{DEMO_ISSUES.map((issue) => (
<li key={issue.id} className="flex gap-2">
<span className="font-mono text-white/40">{issue.id}</span>
<span>{issue.title}</span>
</li>
))}
</ul>
</DemoMessage>
<DemoMessage author="moboudra" time="09:43">
<p>
<Mention /> create a pull request for the first one
</p>
</DemoMessage>
<DemoMessage author="paseo-bot" time="09:48" bot>
<p>
Ran on <span className="font-mono text-white/50">macbook-pro</span>. Opened{" "}
<span className="text-white/80 underline decoration-white/20">
getpaseo/paseo#1234
</span>{" "}
with a fix for the terminal writes.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<span className="rounded-full bg-emerald-400/10 px-2 py-1 text-xs text-emerald-300">
3 files changed
</span>
<span className="rounded-full border border-white/10 px-2 py-1 text-xs text-white/40">
tests passed
</span>
</div>
</DemoMessage>
</div>
</div>
<figcaption className="text-sm text-muted-foreground">
The agent ran on your daemon, with your keys.
</figcaption>
</figure>
);
}
function Mention() {
return <span className="rounded bg-indigo-400/15 px-1 py-0.5 text-indigo-200">@paseo-bot</span>;
}
function DemoMessage({
author,
time,
bot,
children,
}: {
author: string;
time: string;
bot?: boolean;
children: ReactNode;
}) {
return (
<div className="flex gap-3">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.06]">
{bot ? (
<img src="/logo.svg" alt="" className="h-4 w-4" />
) : (
<span className="text-xs font-medium text-white/60">M</span>
)}
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white/90">{author}</span>
{bot && (
<span className="rounded bg-indigo-400/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-200">
Bot
</span>
)}
<span className="text-xs text-white/25">{time}</span>
</div>
<div className="space-y-2 text-sm text-white/70 leading-relaxed">{children}</div>
</div>
</div>
);
}
const AGENT_LOGOS = [
{ name: "Claude Code", icon: <ClaudeCodeIcon className="h-6 w-6" /> },
{ name: "Codex", icon: <CodexIcon className="h-6 w-6" /> },
{ name: "OpenCode", icon: <OpenCodeIcon className="h-6 w-6" /> },
{ name: "Cursor", icon: <CursorIcon className="h-6 w-6" /> },
{ name: "Pi", icon: <PiIcon className="h-6 w-6" /> },
];
const MORE_AGENT_COUNT = AGENT_PAGES.length - AGENT_LOGOS.length;
function Agents() {
return (
<section className="space-y-6">
<h2 className="text-xl font-medium">Runs the agents you already have</h2>
<p className="text-white/70 leading-relaxed max-w-2xl">
The Hub ships no agent of its own, so your team works with the providers, subscriptions, and
skills already configured on your daemons.
</p>
<div className="flex flex-wrap items-center gap-3">
{AGENT_LOGOS.map((agent) => (
<div
key={agent.name}
className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3"
>
<span className="text-white/80">{agent.icon}</span>
<span className="text-sm font-medium">{agent.name}</span>
</div>
))}
<a
href="/agents"
className="rounded-xl border border-dashed border-white/10 bg-white/[0.01] px-4 py-3 text-sm font-medium text-white/50 hover:text-white/80 hover:border-white/20 transition-colors"
>
+{MORE_AGENT_COUNT} more
</a>
</div>
</section>
);
}
const DIAGRAM_SURFACES = [
{ name: "GitHub", icon: <GitHubIcon className="h-5 w-5" /> },
{ name: "Slack", icon: <SlackIcon className="h-5 w-5" /> },
{ name: "Discord", icon: <DiscordIcon className="h-5 w-5" /> },
];
const CLAUDE_MARK = { name: "Claude Code", icon: <ClaudeCodeIcon className="h-4 w-4" /> };
const CODEX_MARK = { name: "Codex", icon: <CodexIcon className="h-4 w-4" /> };
const OPENCODE_MARK = { name: "OpenCode", icon: <OpenCodeIcon className="h-4 w-4" /> };
const PI_MARK = { name: "Pi", icon: <PiIcon className="h-4 w-4" /> };
const DIAGRAM_DAEMONS = [
{ name: "MacBook Pro", agents: [CLAUDE_MARK, CODEX_MARK, OPENCODE_MARK] },
{ name: "Hetzner VM", agents: [CLAUDE_MARK, PI_MARK] },
];
const DIAGRAM_CLIENTS = ["Desktop", "Web", "Mobile", "CLI"];
function Shape() {
return (
<section className="space-y-6">
<h2 className="text-xl font-medium">Works with your existing infrastructure</h2>
<p className="text-white/70 leading-relaxed max-w-2xl">
The Hub connects to the daemons you already run, and your apps keep connecting to those same
daemons.
</p>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6 md:p-8">
<div className="flex flex-col items-center">
<DiagramRow label="Triggers and teammates">
{DIAGRAM_SURFACES.map((surface) => (
<DiagramCard key={surface.name} icon={surface.icon} name={surface.name} />
))}
<DiagramCard name="Your team" />
<div className="flex items-center justify-center rounded-xl border border-dashed border-white/10 bg-white/[0.01] px-4 py-3 text-sm text-white/30">
Future integrations
</div>
</DiagramRow>
<Connector label="webhooks and mentions" />
<div className="w-full max-w-xs rounded-xl border border-white/20 bg-white/[0.06] px-6 py-5 text-center">
<p className="font-medium">Hub</p>
<p className="text-xs text-muted-foreground mt-1">Self-hosted, one per team</p>
</div>
<Connector label="the same RPCs the app and CLI use" />
<DiagramRow label="Your daemons">
{DIAGRAM_DAEMONS.map((daemon) => (
<div
key={daemon.name}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3"
>
<span className="text-white/70">
<MachineIcon />
</span>
<span className="text-sm font-medium">{daemon.name}</span>
<span className="flex items-center gap-2 border-l border-white/10 pl-3 text-white/40">
{daemon.agents.map((agent) => (
<span key={agent.name} title={agent.name}>
{agent.icon}
</span>
))}
</span>
</div>
))}
</DiagramRow>
<Connector label="direct connection or relay" />
<DiagramRow label="Paseo apps">
{DIAGRAM_CLIENTS.map((client) => (
<DiagramCard key={client} name={client} />
))}
</DiagramRow>
</div>
</div>
</section>
);
}
function DiagramRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="w-full space-y-2">
<p className="text-center text-xs uppercase tracking-wide text-white/25">{label}</p>
<div className="flex flex-wrap justify-center gap-3">{children}</div>
</div>
);
}
function DiagramCard({ name, icon }: { name: string; icon?: ReactNode }) {
return (
<div className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
{icon && <span className="text-white/70">{icon}</span>}
<span className="text-sm font-medium">{name}</span>
</div>
);
}
function Connector({ label }: { label: string }) {
return (
<div className="flex flex-col items-center gap-2 py-4">
<div className="h-5 w-px border-l border-dashed border-white/25" />
<p className="text-center text-[11px] text-white/30">{label}</p>
<div className="h-5 w-px border-l border-dashed border-white/25" />
</div>
);
}
function MachineIcon() {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<circle cx="6" cy="6" r="1" />
<circle cx="6" cy="18" r="1" />
</svg>
);
}
function Access() {
return (
<section className="space-y-6">
<h2 className="text-xl font-medium">Getting access</h2>
<p className="text-white/70 leading-relaxed max-w-2xl">
Join the Paseo Discord and DM me. I&apos;m looking for design partners who are comfortable
self-hosting.
</p>
<div className="flex items-center gap-4 pt-2">
<a
href={DISCORD_INVITE_URL}
target="_blank"
rel="noopener noreferrer"
className="rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
>
Join the Discord
</a>
</div>
</section>
);
}
function FaqSection() {
return (
<section className="space-y-6">
<h2 className="text-3xl font-medium">FAQ</h2>
<div className="space-y-6">
<FAQItem question="What is the Hub?">
A separate service that sits above your Paseo daemons. You connect one or more daemons to
it and it gives them GitHub, Slack, and Discord triggers, with collaboration features
planned. Paseo works fully without it.
</FAQItem>
<FAQItem question="Why is it a separate service instead of part of the daemon?">
<p>
The daemon stays one lean executable on one machine. The Hub talks to daemons over the
same RPCs the app and the CLI use, so anyone can build their own hub on top of Paseo.
</p>
<p>
It also has a different job. The Hub is meant to be exposed to the internet, shared with
a team, and run multi-tenant. None of that belongs in a single machine daemon.
</p>
</FAQItem>
<FAQItem question="Why self-hosted first?">
The Hub can trigger executions on the daemons connected to it, so it has to be trusted. I
am not comfortable having people connect their daemons to my infrastructure until the
daemon has stronger authorization primitives and sandboxing. That work is on the roadmap,
and it applies to every Paseo user.
</FAQItem>
<FAQItem question="What do I need to run it?">
A single Node.js application and a Postgres database. You also bring your own GitHub App,
Slack app, and Discord bot credentials, which is the tedious part.
</FAQItem>
<FAQItem question="Will there be a hosted version?">
Planned, once the daemon has stronger authorization primitives and sandboxing. I already
run a Hub for my own repos, and I can walk you through a trial on it if you want to skip
the self-hosted setup. No SLA.
</FAQItem>
<FAQItem question="What else is planned?">
<p>Not built yet, listed so you know where this is going.</p>
<ul className="list-disc space-y-1 pl-5">
<li>Provisioning daemons for your team</li>
<li>Sharing settings and skills across a team</li>
<li>More integrations</li>
</ul>
</FAQItem>
<FAQItem question="Do I have to move my team to a new tool?">
No. The Hub works in the Slack or Discord your team already uses, and on the daemons you
already run. When the work gets serious you switch to the Paseo app on those same daemons.
</FAQItem>
<FAQItem question="Which agents does it run?">
Whichever ones your daemons have configured, with your own subscriptions and skills. The
Hub ships no agent of its own.
</FAQItem>
<FAQItem question="Can the Hub run code on my machines?">
Yes, on the daemons you connect to it and nowhere else. That is the whole point of
triggers, and it&apos;s why access to the beta stays small and trusted.
</FAQItem>
<FAQItem question="How do I get access?">
Join the{" "}
<a
href={DISCORD_INVITE_URL}
target="_blank"
rel="noopener noreferrer"
className={LINK_CLASS}
>
Paseo Discord
</a>{" "}
and DM @moboudra for an invite to the private Hub channel.
</FAQItem>
</div>
</section>
);
}