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:
@@ -1,84 +0,0 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
export interface CloudSignupInput {
|
||||
email: string;
|
||||
name?: string;
|
||||
company?: string;
|
||||
role?: string;
|
||||
message: string;
|
||||
honeypot?: string;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function asString(v: unknown, max: number): string {
|
||||
if (typeof v !== "string") throw new Error("expected string");
|
||||
const trimmed = v.trim();
|
||||
if (trimmed.length > max) throw new Error("field too long");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validate(raw: unknown): CloudSignupInput {
|
||||
if (typeof raw !== "object" || raw === null) throw new Error("invalid input");
|
||||
const r = raw as Record<string, unknown>;
|
||||
|
||||
const email = asString(r.email, 320);
|
||||
if (!EMAIL_RE.test(email)) throw new Error("invalid email");
|
||||
|
||||
const message = asString(r.message, 4000);
|
||||
if (message.length === 0) throw new Error("message required");
|
||||
|
||||
const name = r.name === undefined || r.name === "" ? undefined : asString(r.name, 200);
|
||||
const company =
|
||||
r.company === undefined || r.company === "" ? undefined : asString(r.company, 200);
|
||||
const role = r.role === undefined || r.role === "" ? undefined : asString(r.role, 200);
|
||||
const honeypot = typeof r.honeypot === "string" ? r.honeypot : "";
|
||||
|
||||
return { email, name, company, role, message, honeypot };
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
function buildEmbed(input: CloudSignupInput) {
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [
|
||||
{ name: "Email", value: truncate(input.email, 1024), inline: true },
|
||||
];
|
||||
if (input.name) {
|
||||
fields.push({ name: "Name", value: truncate(input.name, 1024), inline: true });
|
||||
}
|
||||
if (input.company) {
|
||||
fields.push({ name: "Company", value: truncate(input.company, 1024), inline: true });
|
||||
}
|
||||
if (input.role) {
|
||||
fields.push({ name: "Role", value: truncate(input.role, 1024), inline: true });
|
||||
}
|
||||
fields.push({ name: "Message", value: truncate(input.message, 1024) });
|
||||
|
||||
return {
|
||||
title: "Paseo Cloud signup",
|
||||
color: 0x5865f2,
|
||||
fields,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const submitCloudSignup = createServerFn({ method: "POST" })
|
||||
.inputValidator(validate)
|
||||
.handler(async ({ data }) => {
|
||||
if (data.honeypot) return { ok: true };
|
||||
|
||||
const url = (env as { DISCORD_WEBHOOK_URL?: string }).DISCORD_WEBHOOK_URL;
|
||||
if (!url) throw new Error("webhook not configured");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ embeds: [buildEmbed(data)] }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`webhook ${res.status}`);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Agent provider brand marks, shared by the landing page and the Hub page.
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
export function ClaudeCodeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCodeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="96 64 288 384"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CursorIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PiIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 800 800"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path d="M517.36 400 H634.72 V634.72 H517.36 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Third-party brand marks. Paths come from Simple Icons; Slack is the 13.x path,
|
||||
// kept here because Slack was pulled from later releases.
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
export function GitHubIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlackIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -43,8 +43,15 @@ import {
|
||||
GlobeIcon,
|
||||
} from "~/downloads";
|
||||
import { useRelease } from "~/routes/__root";
|
||||
import { Mic } from "lucide-react";
|
||||
import { HeroMockup } from "~/components/hero-mockup";
|
||||
import {
|
||||
ClaudeCodeIcon,
|
||||
CodexIcon,
|
||||
CursorIcon,
|
||||
OpenCodeIcon,
|
||||
PiIcon,
|
||||
} from "~/components/agent-icons";
|
||||
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
|
||||
import { ClaudeIcon } from "~/components/mockup";
|
||||
import { FAQItem } from "~/components/faq-item";
|
||||
import { SiteFooter } from "~/components/site-footer";
|
||||
@@ -90,11 +97,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<SocialProofWall />
|
||||
<MultiProviderSection />
|
||||
<SelfHostedSection />
|
||||
<HubSection />
|
||||
<WorkflowSection />
|
||||
<SplitPanelsSection />
|
||||
<ServiceProxySection />
|
||||
<ShortcutsSection />
|
||||
<LocalVoiceSection />
|
||||
<CLISection />
|
||||
<FAQ />
|
||||
<SponsorCTA />
|
||||
@@ -246,10 +250,12 @@ function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) {
|
||||
function FeatureSection({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -259,16 +265,31 @@ function FeatureSection({
|
||||
viewport={VIEWPORT_60}
|
||||
transition={EASE_OUT_05}
|
||||
>
|
||||
<SectionTitle title={title} description={description} />
|
||||
<SectionTitle title={title} description={description} badge={badge} />
|
||||
{children}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title, description }: { title: string; description: string }) {
|
||||
function SectionTitle({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-12 space-y-2">
|
||||
<h2 className="text-3xl font-medium">{title}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-3xl font-medium">{title}</h2>
|
||||
{badge && (
|
||||
<span className="rounded-full bg-emerald-400/10 px-2 py-1 text-xs text-emerald-300">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground max-w-lg">{description}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -837,371 +858,6 @@ function ShipPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function SplitPanelsSection() {
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Split panels"
|
||||
description="Open agents, browsers, terminals, diffs, and logs in the same workspace. Split them side by side or group them in tabs."
|
||||
>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3">
|
||||
<div className="grid gap-3 md:h-[360px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<PanelTile label="Agent" className="min-h-48 md:min-h-0" />
|
||||
<div className="grid gap-3 md:grid-rows-[1fr_0.75fr]">
|
||||
<PanelTile label="Browser" className="min-h-36" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<PanelTile label="Terminal" className="min-h-28" />
|
||||
<PanelTile label="Diff" className="min-h-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTile({ label, className }: { label: string; className: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.03] text-sm text-white/70 ${className}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceProxySection() {
|
||||
const workspaces = [
|
||||
{ name: "fix-auth", url: "web.fix-auth.my-app.localhost" },
|
||||
{ name: "add-search", url: "web.add-search.my-app.localhost" },
|
||||
{ name: "upgrade-deps", url: "web.upgrade-deps.my-app.localhost" },
|
||||
];
|
||||
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Forget about ports"
|
||||
description="When agents work in parallel, they all run dev servers. Paseo gives each one a URL based on the branch name, no port conflicts, no guessing."
|
||||
>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] overflow-hidden">
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
{/* Project */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-white/40"
|
||||
>
|
||||
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-white/60">my-app</span>
|
||||
</div>
|
||||
|
||||
{/* Workspaces indented */}
|
||||
<div className="pl-6 space-y-2">
|
||||
{workspaces.map((ws) => (
|
||||
<div key={ws.name} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
<span className="text-sm text-white/80">{ws.name}</span>
|
||||
<span className="text-xs text-white/25 font-mono">npm run dev</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white/30">{ws.url}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutsSection() {
|
||||
const shortcuts = [
|
||||
{ keys: ["⌘", "1-9"], action: "Switch panels" },
|
||||
{ keys: ["⌘", "D"], action: "Split vertical" },
|
||||
{ keys: ["⌘", "Shift", "D"], action: "Split horizontal" },
|
||||
{ keys: ["⌘", "W"], action: "Close panel" },
|
||||
{ keys: ["⌘", "N"], action: "New agent" },
|
||||
{ keys: ["⌘", "K"], action: "Command palette" },
|
||||
];
|
||||
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Keyboard-first"
|
||||
description="Every action has a shortcut. Panels, splits, agents - all from the keyboard."
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{shortcuts.map((s) => (
|
||||
<div
|
||||
key={s.action}
|
||||
className="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5"
|
||||
>
|
||||
<span className="text-sm text-white/60">{s.action}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{s.keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-white/10 text-white/50 font-mono"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceBarProps {
|
||||
index: number;
|
||||
barCount: number;
|
||||
}
|
||||
|
||||
function VoiceBar({ index, barCount }: VoiceBarProps) {
|
||||
const style = React.useMemo(() => {
|
||||
const center = barCount / 2;
|
||||
const dist = Math.abs(index - center) / center;
|
||||
const envelope = 1 - dist * dist;
|
||||
const minH = 4;
|
||||
const maxH = 56;
|
||||
const baseH = minH + (maxH - minH) * envelope;
|
||||
const jitter = Math.sin(index * 2.3) * 0.3 + Math.cos(index * 1.7) * 0.2;
|
||||
const h = Math.max(minH, baseH * (0.5 + 0.5 * Math.abs(jitter + Math.sin(index * 0.8))));
|
||||
return {
|
||||
height: h,
|
||||
animationName: "voice-bar",
|
||||
animationDuration: `${800 + (index % 5) * 200}ms`,
|
||||
animationTimingFunction: "ease-in-out",
|
||||
animationIterationCount: "infinite",
|
||||
animationDirection: "alternate" as const,
|
||||
animationDelay: `${(index % 7) * 80}ms`,
|
||||
};
|
||||
}, [index, barCount]);
|
||||
return <div className="w-[3px] rounded-full bg-white/30" style={style} />;
|
||||
}
|
||||
|
||||
const VOICE_BAR_COUNT = 48;
|
||||
const VOICE_BAR_INDICES = Array.from({ length: VOICE_BAR_COUNT }, (_, i) => i);
|
||||
|
||||
function VoiceWaveform() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-[3px] h-16">
|
||||
{VOICE_BAR_INDICES.map((i) => (
|
||||
<VoiceBar key={`voice-bar-${i}`} index={i} barCount={VOICE_BAR_COUNT} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const USER_WORDS =
|
||||
"Refactor the auth middleware to use the new session store, then run the test suite".split(" ");
|
||||
const RESPONSE_WORDS =
|
||||
"I'll update the auth middleware to use SessionStore instead of the legacy cookie-based approach. Let me refactor the middleware and update the tests.".split(
|
||||
" ",
|
||||
);
|
||||
const DICTATION_LAG = 2;
|
||||
const RESPONSE_LAG = 3;
|
||||
const WORD_APPEAR_MS = 150;
|
||||
const RESPONSE_WORD_MS = 60;
|
||||
const PHASE_GAP_MS = 800;
|
||||
const LOOP_PAUSE_MS = 3000;
|
||||
|
||||
type VoicePhase =
|
||||
| "dictation"
|
||||
| "dictation-flush"
|
||||
| "pause"
|
||||
| "response"
|
||||
| "response-flush"
|
||||
| "done";
|
||||
|
||||
function useVoiceConversation() {
|
||||
const [phase, setPhase] = React.useState<VoicePhase>("dictation");
|
||||
const [wordIndex, setWordIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (phase === "dictation") {
|
||||
if (wordIndex < USER_WORDS.length) {
|
||||
const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
setPhase("dictation-flush");
|
||||
setWordIndex(0);
|
||||
return;
|
||||
}
|
||||
if (phase === "dictation-flush") {
|
||||
if (wordIndex < DICTATION_LAG) {
|
||||
const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPhase("pause");
|
||||
}, PHASE_GAP_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (phase === "pause") {
|
||||
const t = setTimeout(() => {
|
||||
setPhase("response");
|
||||
setWordIndex(0);
|
||||
}, PHASE_GAP_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (phase === "response") {
|
||||
if (wordIndex < RESPONSE_WORDS.length) {
|
||||
const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
setPhase("response-flush");
|
||||
setWordIndex(0);
|
||||
return;
|
||||
}
|
||||
if (phase === "response-flush") {
|
||||
if (wordIndex < RESPONSE_LAG) {
|
||||
const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
setPhase("done");
|
||||
}, LOOP_PAUSE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (phase === "done") {
|
||||
const t = setTimeout(() => {
|
||||
setPhase("dictation");
|
||||
setWordIndex(0);
|
||||
}, 0);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [phase, wordIndex]);
|
||||
|
||||
// Compute effective word indices for rendering
|
||||
let dictationWordIndex: number;
|
||||
if (phase === "dictation") {
|
||||
dictationWordIndex = wordIndex;
|
||||
} else if (phase === "dictation-flush") {
|
||||
dictationWordIndex = USER_WORDS.length + wordIndex;
|
||||
} else {
|
||||
dictationWordIndex = USER_WORDS.length + DICTATION_LAG;
|
||||
}
|
||||
|
||||
let responseWordIndex: number;
|
||||
if (phase === "response") {
|
||||
responseWordIndex = wordIndex;
|
||||
} else if (phase === "response-flush") {
|
||||
responseWordIndex = RESPONSE_WORDS.length + wordIndex;
|
||||
} else if (phase === "done") {
|
||||
responseWordIndex = RESPONSE_WORDS.length + RESPONSE_LAG;
|
||||
} else {
|
||||
responseWordIndex = 0;
|
||||
}
|
||||
|
||||
const showResponse = phase === "response" || phase === "response-flush" || phase === "done";
|
||||
|
||||
return { dictationWordIndex, responseWordIndex, showResponse };
|
||||
}
|
||||
|
||||
function makeWordKey(words: string[], i: number): string {
|
||||
const word = words[i];
|
||||
let occurrence = 0;
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (words[j] === word) occurrence++;
|
||||
}
|
||||
return `${word}#${occurrence}`;
|
||||
}
|
||||
|
||||
function WordSpan({ word, confirmed }: { word: string; confirmed: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`transition-colors duration-300 ${confirmed ? "text-white/90" : "text-white/40"}`}
|
||||
>
|
||||
{word}{" "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingWords({
|
||||
words,
|
||||
wordIndex,
|
||||
confirmLag = 2,
|
||||
}: {
|
||||
words: string[];
|
||||
wordIndex: number;
|
||||
confirmLag?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Invisible full text to reserve height at any viewport width */}
|
||||
<p className="text-sm leading-relaxed invisible" aria-hidden>
|
||||
{words.join(" ")}
|
||||
</p>
|
||||
{/* Visible streaming text overlaid */}
|
||||
<p className="text-sm leading-relaxed absolute inset-0">
|
||||
{words.map((word, i) => {
|
||||
if (i >= wordIndex) return null;
|
||||
const confirmed = i < wordIndex - confirmLag;
|
||||
return <WordSpan key={makeWordKey(words, i)} word={word} confirmed={confirmed} />;
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalVoiceSection() {
|
||||
const { dictationWordIndex, responseWordIndex, showResponse } = useVoiceConversation();
|
||||
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Voice control, fully local"
|
||||
description="Fully local voice stack. Speech-to-text and text-to-speech run entirely on your machine, nothing leaves your network."
|
||||
>
|
||||
<div className="relative w-full rounded-2xl border border-white/10 bg-white/[0.02] overflow-hidden">
|
||||
<div className="px-6 pt-8 pb-6 space-y-3">
|
||||
{/* Waveform area */}
|
||||
<div className="relative">
|
||||
<VoiceWaveform />
|
||||
</div>
|
||||
|
||||
{/* User dictation */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<Mic size={16} className="text-white/60" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<StreamingWords
|
||||
words={USER_WORDS}
|
||||
wordIndex={dictationWordIndex}
|
||||
confirmLag={DICTATION_LAG}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent response — always rendered to reserve space */}
|
||||
<div
|
||||
className={`flex items-start gap-3 transition-opacity duration-300 ${showResponse ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<ClaudeIcon size={16} className="text-white/60" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<StreamingWords
|
||||
words={RESPONSE_WORDS}
|
||||
wordIndex={responseWordIndex}
|
||||
confirmLag={RESPONSE_LAG}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
function GetStarted() {
|
||||
return (
|
||||
<div className="pt-10">
|
||||
@@ -1308,87 +964,6 @@ function ServerInstallButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClaudeCodeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenCodeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="96 64 288 384"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CursorIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PiIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 800 800"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path d="M517.36 400 H634.72 V634.72 H517.36 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const bashKeywords = new Set([
|
||||
"while",
|
||||
"do",
|
||||
@@ -1894,6 +1469,40 @@ function FAQ() {
|
||||
);
|
||||
}
|
||||
|
||||
const HUB_SURFACES = [
|
||||
{ name: "GitHub", icon: <GitHubIcon className="h-7 w-7" /> },
|
||||
{ name: "Slack", icon: <SlackIcon className="h-7 w-7" /> },
|
||||
{ name: "Discord", icon: <DiscordIcon className="h-7 w-7" /> },
|
||||
];
|
||||
|
||||
function HubSection() {
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Paseo Hub"
|
||||
badge="New"
|
||||
description="An optional service you run on top of your daemons. It gives them triggers from GitHub, Slack, and Discord, and access for your team."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{HUB_SURFACES.map((surface) => (
|
||||
<div
|
||||
key={surface.name}
|
||||
className="flex items-center justify-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="font-medium">{surface.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href="/hub"
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-white/10 border border-white/20 px-5 py-2.5 text-sm font-medium text-white hover:bg-white/15 transition-colors"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorCTA() {
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -33,6 +33,12 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
|
||||
>
|
||||
Changelog
|
||||
</a>
|
||||
<a
|
||||
href="/hub"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Hub
|
||||
</a>
|
||||
<a
|
||||
href="/docs/cli"
|
||||
className="block text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "~/styles.css";
|
||||
import { DiscordIcon, GitHubIcon } from "~/components/brand-icons";
|
||||
import { useStars } from "~/routes/__root";
|
||||
|
||||
export function SiteHeader() {
|
||||
@@ -47,16 +48,7 @@ export function SiteHeader() {
|
||||
aria-label="Discord"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
<DiscordIcon width="18" height="18" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/getpaseo/paseo"
|
||||
@@ -65,15 +57,7 @@ export function SiteHeader() {
|
||||
aria-label={stars ? `GitHub, ${stars} stars` : "GitHub"}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
|
||||
</svg>
|
||||
<GitHubIcon width="18" height="18" />
|
||||
{stars && <span className="text-sm">{stars}</span>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ ${agents}
|
||||
|
||||
- [Changelog](${SITE_URL}/changelog): Release notes for the Paseo daemon, CLI, desktop, and mobile apps.
|
||||
- [Download](${SITE_URL}/download): Install Paseo on Mac, Windows, Linux, iOS, Android, or run the web app.
|
||||
- [Paseo Cloud](${SITE_URL}/cloud): Waitlist for the hosted multi-user version of Paseo.
|
||||
- [Paseo Hub](${SITE_URL}/hub): Optional service above your daemons that adds GitHub, Slack, and Discord triggers. Self-hosted, private beta.
|
||||
- [Blog](${SITE_URL}/blog): Updates and technical posts from the Paseo team.
|
||||
- [Privacy](${SITE_URL}/privacy): Privacy policy.
|
||||
- [GitHub](https://github.com/getpaseo/paseo): Source code, issues, and releases.
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Route as MinionCodeRouteImport } from "./routes/minion-code";
|
||||
import { Route as KimiRouteImport } from "./routes/kimi";
|
||||
import { Route as KiloRouteImport } from "./routes/kilo";
|
||||
import { Route as JunieRouteImport } from "./routes/junie";
|
||||
import { Route as HubRouteImport } from "./routes/hub";
|
||||
import { Route as HermesRouteImport } from "./routes/hermes";
|
||||
import { Route as GrokRouteImport } from "./routes/grok";
|
||||
import { Route as GooseRouteImport } from "./routes/goose";
|
||||
@@ -46,7 +47,6 @@ import { Route as CortexCodeRouteImport } from "./routes/cortex-code";
|
||||
import { Route as CopilotRouteImport } from "./routes/copilot";
|
||||
import { Route as CodexRouteImport } from "./routes/codex";
|
||||
import { Route as CodebuddyRouteImport } from "./routes/codebuddy";
|
||||
import { Route as CloudRouteImport } from "./routes/cloud";
|
||||
import { Route as ClineRouteImport } from "./routes/cline";
|
||||
import { Route as ClaudeCodeRouteImport } from "./routes/claude-code";
|
||||
import { Route as ChangelogRouteImport } from "./routes/changelog";
|
||||
@@ -154,6 +154,11 @@ const JunieRoute = JunieRouteImport.update({
|
||||
path: "/junie",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const HubRoute = HubRouteImport.update({
|
||||
id: "/hub",
|
||||
path: "/hub",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const HermesRoute = HermesRouteImport.update({
|
||||
id: "/hermes",
|
||||
path: "/hermes",
|
||||
@@ -254,11 +259,6 @@ const CodebuddyRoute = CodebuddyRouteImport.update({
|
||||
path: "/codebuddy",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const CloudRoute = CloudRouteImport.update({
|
||||
id: "/cloud",
|
||||
path: "/cloud",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const ClineRoute = ClineRouteImport.update({
|
||||
id: "/cline",
|
||||
path: "/cline",
|
||||
@@ -378,7 +378,6 @@ export interface FileRoutesByFullPath {
|
||||
"/changelog": typeof ChangelogRoute;
|
||||
"/claude-code": typeof ClaudeCodeRoute;
|
||||
"/cline": typeof ClineRoute;
|
||||
"/cloud": typeof CloudRoute;
|
||||
"/codebuddy": typeof CodebuddyRoute;
|
||||
"/codex": typeof CodexRoute;
|
||||
"/copilot": typeof CopilotRoute;
|
||||
@@ -399,6 +398,7 @@ export interface FileRoutesByFullPath {
|
||||
"/goose": typeof GooseRoute;
|
||||
"/grok": typeof GrokRoute;
|
||||
"/hermes": typeof HermesRoute;
|
||||
"/hub": typeof HubRoute;
|
||||
"/junie": typeof JunieRoute;
|
||||
"/kilo": typeof KiloRoute;
|
||||
"/kimi": typeof KimiRoute;
|
||||
@@ -438,7 +438,6 @@ export interface FileRoutesByTo {
|
||||
"/changelog": typeof ChangelogRoute;
|
||||
"/claude-code": typeof ClaudeCodeRoute;
|
||||
"/cline": typeof ClineRoute;
|
||||
"/cloud": typeof CloudRoute;
|
||||
"/codebuddy": typeof CodebuddyRoute;
|
||||
"/codex": typeof CodexRoute;
|
||||
"/copilot": typeof CopilotRoute;
|
||||
@@ -458,6 +457,7 @@ export interface FileRoutesByTo {
|
||||
"/goose": typeof GooseRoute;
|
||||
"/grok": typeof GrokRoute;
|
||||
"/hermes": typeof HermesRoute;
|
||||
"/hub": typeof HubRoute;
|
||||
"/junie": typeof JunieRoute;
|
||||
"/kilo": typeof KiloRoute;
|
||||
"/kimi": typeof KimiRoute;
|
||||
@@ -499,7 +499,6 @@ export interface FileRoutesById {
|
||||
"/changelog": typeof ChangelogRoute;
|
||||
"/claude-code": typeof ClaudeCodeRoute;
|
||||
"/cline": typeof ClineRoute;
|
||||
"/cloud": typeof CloudRoute;
|
||||
"/codebuddy": typeof CodebuddyRoute;
|
||||
"/codex": typeof CodexRoute;
|
||||
"/copilot": typeof CopilotRoute;
|
||||
@@ -520,6 +519,7 @@ export interface FileRoutesById {
|
||||
"/goose": typeof GooseRoute;
|
||||
"/grok": typeof GrokRoute;
|
||||
"/hermes": typeof HermesRoute;
|
||||
"/hub": typeof HubRoute;
|
||||
"/junie": typeof JunieRoute;
|
||||
"/kilo": typeof KiloRoute;
|
||||
"/kimi": typeof KimiRoute;
|
||||
@@ -562,7 +562,6 @@ export interface FileRouteTypes {
|
||||
| "/changelog"
|
||||
| "/claude-code"
|
||||
| "/cline"
|
||||
| "/cloud"
|
||||
| "/codebuddy"
|
||||
| "/codex"
|
||||
| "/copilot"
|
||||
@@ -583,6 +582,7 @@ export interface FileRouteTypes {
|
||||
| "/goose"
|
||||
| "/grok"
|
||||
| "/hermes"
|
||||
| "/hub"
|
||||
| "/junie"
|
||||
| "/kilo"
|
||||
| "/kimi"
|
||||
@@ -622,7 +622,6 @@ export interface FileRouteTypes {
|
||||
| "/changelog"
|
||||
| "/claude-code"
|
||||
| "/cline"
|
||||
| "/cloud"
|
||||
| "/codebuddy"
|
||||
| "/codex"
|
||||
| "/copilot"
|
||||
@@ -642,6 +641,7 @@ export interface FileRouteTypes {
|
||||
| "/goose"
|
||||
| "/grok"
|
||||
| "/hermes"
|
||||
| "/hub"
|
||||
| "/junie"
|
||||
| "/kilo"
|
||||
| "/kimi"
|
||||
@@ -682,7 +682,6 @@ export interface FileRouteTypes {
|
||||
| "/changelog"
|
||||
| "/claude-code"
|
||||
| "/cline"
|
||||
| "/cloud"
|
||||
| "/codebuddy"
|
||||
| "/codex"
|
||||
| "/copilot"
|
||||
@@ -703,6 +702,7 @@ export interface FileRouteTypes {
|
||||
| "/goose"
|
||||
| "/grok"
|
||||
| "/hermes"
|
||||
| "/hub"
|
||||
| "/junie"
|
||||
| "/kilo"
|
||||
| "/kimi"
|
||||
@@ -744,7 +744,6 @@ export interface RootRouteChildren {
|
||||
ChangelogRoute: typeof ChangelogRoute;
|
||||
ClaudeCodeRoute: typeof ClaudeCodeRoute;
|
||||
ClineRoute: typeof ClineRoute;
|
||||
CloudRoute: typeof CloudRoute;
|
||||
CodebuddyRoute: typeof CodebuddyRoute;
|
||||
CodexRoute: typeof CodexRoute;
|
||||
CopilotRoute: typeof CopilotRoute;
|
||||
@@ -765,6 +764,7 @@ export interface RootRouteChildren {
|
||||
GooseRoute: typeof GooseRoute;
|
||||
GrokRoute: typeof GrokRoute;
|
||||
HermesRoute: typeof HermesRoute;
|
||||
HubRoute: typeof HubRoute;
|
||||
JunieRoute: typeof JunieRoute;
|
||||
KiloRoute: typeof KiloRoute;
|
||||
KimiRoute: typeof KimiRoute;
|
||||
@@ -912,6 +912,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof JunieRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/hub": {
|
||||
id: "/hub";
|
||||
path: "/hub";
|
||||
fullPath: "/hub";
|
||||
preLoaderRoute: typeof HubRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/hermes": {
|
||||
id: "/hermes";
|
||||
path: "/hermes";
|
||||
@@ -1052,13 +1059,6 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof CodebuddyRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/cloud": {
|
||||
id: "/cloud";
|
||||
path: "/cloud";
|
||||
fullPath: "/cloud";
|
||||
preLoaderRoute: typeof CloudRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/cline": {
|
||||
id: "/cline";
|
||||
path: "/cline";
|
||||
@@ -1244,7 +1244,6 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ChangelogRoute: ChangelogRoute,
|
||||
ClaudeCodeRoute: ClaudeCodeRoute,
|
||||
ClineRoute: ClineRoute,
|
||||
CloudRoute: CloudRoute,
|
||||
CodebuddyRoute: CodebuddyRoute,
|
||||
CodexRoute: CodexRoute,
|
||||
CopilotRoute: CopilotRoute,
|
||||
@@ -1265,6 +1264,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
GooseRoute: GooseRoute,
|
||||
GrokRoute: GrokRoute,
|
||||
HermesRoute: HermesRoute,
|
||||
HubRoute: HubRoute,
|
||||
JunieRoute: JunieRoute,
|
||||
KiloRoute: KiloRoute,
|
||||
KimiRoute: KimiRoute,
|
||||
|
||||
@@ -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'll be in touch. If you don'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,11 @@ export default {
|
||||
return Response.redirect(url.toString(), 301);
|
||||
}
|
||||
|
||||
if (url.pathname === "/cloud" || url.pathname === "/cloud/") {
|
||||
url.pathname = "/hub";
|
||||
return Response.redirect(url.toString(), 301);
|
||||
}
|
||||
|
||||
const altRedirectMatch = url.pathname.match(/^\/docs\/alternatives\/(.+?)\/?$/);
|
||||
if (altRedirectMatch) {
|
||||
url.pathname = `/alternatives/${altRedirectMatch[1]}`;
|
||||
|
||||
@@ -41,9 +41,9 @@ function discoverAgentRoutes(): string[] {
|
||||
"agents",
|
||||
"blog",
|
||||
"changelog",
|
||||
"cloud",
|
||||
"docs",
|
||||
"download",
|
||||
"hub",
|
||||
"index",
|
||||
"sponsor",
|
||||
"privacy",
|
||||
@@ -83,8 +83,8 @@ const sitemapPages = [
|
||||
"/",
|
||||
"/agents",
|
||||
"/changelog",
|
||||
"/cloud",
|
||||
"/download",
|
||||
"/hub",
|
||||
"/privacy",
|
||||
...discoverAgentRoutes(),
|
||||
...discoverAlternativeRoutes(),
|
||||
|
||||
Reference in New Issue
Block a user