mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
feat(website): add agent landing overlays with Pi content
This commit is contained in:
@@ -59,3 +59,72 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
|
||||
await resizeToPhone(page);
|
||||
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
|
||||
});
|
||||
|
||||
async function openAgentPage(page: Page, slug: string, width: number) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`/${slug}`);
|
||||
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectComposerModel(page: Page, label: string) {
|
||||
const desktop = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(desktop.getByText(label, { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Extend", exact: true }).click();
|
||||
await expect(desktop.getByText(label, { exact: true })).toHaveCount(1);
|
||||
await expect(desktop.getByText(label, { exact: true })).toBeVisible();
|
||||
await viewPhones(page);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Paseo agent chat", exact: true })
|
||||
.getByText(label, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectPiOverlay(page: Page) {
|
||||
await expect(page).toHaveTitle("Pi Agent Mobile and Desktop App, Open Source");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||
"Open source app for the Pi coding agent",
|
||||
);
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
"href",
|
||||
"https://paseo.sh/pi",
|
||||
);
|
||||
await expectComposerModel(page, "Pi · Sonnet 4.5");
|
||||
const section = page.getByRole("region", { name: "Your Pi setup, with a desktop and mobile UI" });
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
await expect(section.getByRole("heading", { name: "Bring your extensions" })).toBeVisible();
|
||||
await expect(
|
||||
section.getByText(/pi-subagents, pi-ask-user, pi-mcp-adapter and pi-goal-x/),
|
||||
).toBeVisible();
|
||||
await expect(section.getByRole("link", { name: "Set up Paseo" })).toHaveAttribute(
|
||||
"href",
|
||||
"/docs",
|
||||
);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||
await page.evaluate(() => window.innerWidth),
|
||||
);
|
||||
}
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`Pi has its own content and composer at ${width}px`, async ({ page }) => {
|
||||
await openAgentPage(page, "pi", width);
|
||||
await expectPiOverlay(page);
|
||||
});
|
||||
|
||||
test(`an agent without an overlay keeps the shared page at ${width}px`, async ({ page }) => {
|
||||
await openAgentPage(page, "opencode", width);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||
"Open source app for OpenCode",
|
||||
);
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Run OpenCode on your machine, drive it from your phone or desktop. Open source on both ends, your code stays local.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Your Pi setup, with a desktop and mobile UI" }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerModel(page, "GPT-5.6-Sol");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ export function agentRouteOptions(slug: string) {
|
||||
return {
|
||||
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
|
||||
component: function AgentLandingPage() {
|
||||
return <LandingPage title={page.title} subtitle={page.subtitle} />;
|
||||
return (
|
||||
<LandingPage
|
||||
title={page.title}
|
||||
subtitle={page.subtitle}
|
||||
mockup={page.mockup}
|
||||
section={page.section}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DESIGN_WIDTH,
|
||||
MOCKUP_STATES,
|
||||
type MockupStateId,
|
||||
type MockupAgent,
|
||||
MockupWindow,
|
||||
ScaledMockup,
|
||||
} from "~/components/mockup";
|
||||
@@ -15,7 +16,7 @@ const ALT =
|
||||
|
||||
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
|
||||
|
||||
export function HeroMockup() {
|
||||
export function HeroMockup({ agent }: { agent?: MockupAgent }) {
|
||||
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
|
||||
|
||||
return (
|
||||
@@ -39,7 +40,7 @@ export function HeroMockup() {
|
||||
label={ALT}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow state={state} />
|
||||
<MockupWindow state={state} agent={agent} />
|
||||
</ScaledMockup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ const AGENT_LIST_GRID_STYLE = {
|
||||
const PHONE_PERSPECTIVE_STYLE = { minHeight: 480, perspective: 700 };
|
||||
import { CursorFieldProvider } from "~/components/butterfly";
|
||||
import { CommandDialog } from "~/components/command-dialog";
|
||||
import { AGENT_PAGES } from "~/data/agent-pages";
|
||||
import { AGENT_PAGES, type AgentPage } from "~/data/agent-pages";
|
||||
import {
|
||||
appStoreUrl,
|
||||
playStoreUrl,
|
||||
@@ -91,12 +91,12 @@ import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
||||
import { SiteHeader } from "~/components/site-header";
|
||||
import "~/styles.css";
|
||||
|
||||
interface LandingPageProps {
|
||||
interface LandingPageProps extends Pick<AgentPage, "mockup" | "section"> {
|
||||
title: React.ReactNode;
|
||||
subtitle: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
export function LandingPage({ title, subtitle, mockup, section }: LandingPageProps) {
|
||||
return (
|
||||
<CursorFieldProvider>
|
||||
{/* Hero section with background image */}
|
||||
@@ -115,13 +115,15 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
className="relative px-6 md:px-8 pt-4 md:pt-8 pb-8 md:pb-16"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<HeroMockup />
|
||||
<HeroMockup agent={mockup} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{section ? <AgentSection section={section} /> : null}
|
||||
|
||||
{/* Phone showcase */}
|
||||
<PhoneShowcase />
|
||||
<PhoneShowcase agent={mockup} />
|
||||
|
||||
{/* Content section */}
|
||||
<div className="landing-content bg-background">
|
||||
@@ -144,6 +146,32 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSection({ section }: { section: NonNullable<AgentPage["section"]> }) {
|
||||
return (
|
||||
<section aria-label={section.title} className="mx-auto max-w-5xl px-6 py-16 md:py-24">
|
||||
<h2 className="max-w-2xl text-3xl font-medium tracking-tight md:text-4xl">{section.title}</h2>
|
||||
<p className="mt-4 max-w-2xl text-base leading-relaxed text-muted-foreground">
|
||||
{section.description}
|
||||
</p>
|
||||
<div className="mt-10 grid gap-8 md:grid-cols-3">
|
||||
{section.items.map((item) => (
|
||||
<div key={item.title} className="border-t border-white/10 pt-5">
|
||||
<h3 className="text-lg font-medium">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={section.link.href}
|
||||
className="mt-8 inline-flex items-center gap-2 text-sm text-white/80 hover:text-white"
|
||||
>
|
||||
{section.link.label}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav() {
|
||||
return (
|
||||
<nav className="mb-20 md:mb-24">
|
||||
@@ -1080,7 +1108,7 @@ function ServerInstallButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneShowcase() {
|
||||
function PhoneShowcase({ agent }: { agent?: AgentPage["mockup"] }) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const textInView = useInView(containerRef, { once: true, margin: "-80px" });
|
||||
|
||||
@@ -1174,7 +1202,7 @@ function PhoneShowcase() {
|
||||
aria-label="Paseo agent chat"
|
||||
>
|
||||
<PhoneFrame time="18:53">
|
||||
<MobileChat />
|
||||
<MobileChat agent={agent} />
|
||||
</PhoneFrame>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
|
||||
/** Optional branding for the shared desktop and phone composer mockups. */
|
||||
export interface MockupAgent {
|
||||
name: string;
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function MockupAgentIcon({ agent, size }: { agent: MockupAgent; size: number }) {
|
||||
return <agent.Icon width={size} height={size} className="shrink-0" />;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MockupAgentIcon, type MockupAgent } from "./agent";
|
||||
import {
|
||||
AudioLines,
|
||||
BookOpen,
|
||||
@@ -188,10 +189,10 @@ export function PluginBuildTranscript() {
|
||||
|
||||
// --- composer ---------------------------------------------------------------
|
||||
|
||||
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
function ControlPill({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
|
||||
{icon}
|
||||
{children}
|
||||
{label}
|
||||
<ChevronDown size={11} className="text-mock-fg-xmuted" />
|
||||
</span>
|
||||
@@ -213,9 +214,11 @@ function BusyRing() {
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
agent,
|
||||
showProgress = true,
|
||||
busy = true,
|
||||
}: {
|
||||
agent?: MockupAgent;
|
||||
showProgress?: boolean;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
@@ -243,9 +246,13 @@ export function Composer({
|
||||
</div>
|
||||
<div className="flex items-center gap-[17px] pt-[26px]">
|
||||
<Plus size={15} className="text-mock-fg-muted" />
|
||||
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
|
||||
<ControlPill icon={EFFORT_ICON} label="Medium" />
|
||||
<ControlPill icon={ACCESS_ICON} label="Full access" />
|
||||
<ControlPill
|
||||
label={agent ? `${agent.name} · ${agent.model ?? "GPT-5.6-Sol"}` : "GPT-5.6-Sol"}
|
||||
>
|
||||
{agent ? <MockupAgentIcon agent={agent} size={13} /> : MODEL_ICON}
|
||||
</ControlPill>
|
||||
<ControlPill label="Medium">{EFFORT_ICON}</ControlPill>
|
||||
<ControlPill label="Full access">{ACCESS_ICON}</ControlPill>
|
||||
<Zap size={14} className="text-mock-fg-muted" />
|
||||
<ListTodo size={14} className="text-mock-fg-muted" />
|
||||
<span className="flex-1" />
|
||||
|
||||
@@ -7,3 +7,4 @@ export { MobileSidebar } from "./mobile/sidebar";
|
||||
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
|
||||
export { MockupWindow } from "./window";
|
||||
export { ScaledMockup } from "./scaled-mockup";
|
||||
export type { MockupAgent } from "./agent";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MockupAgentIcon, type MockupAgent } from "../agent";
|
||||
// Mobile agent chat — the counterpart of the desktop chat pane, drawn at phone
|
||||
// width. Maps to the "agent chat" reference screenshot: header, collapsed slash
|
||||
// command, muted tool-call rows, an assistant turn, the diff-stat pill, and the
|
||||
@@ -115,7 +116,7 @@ function Transcript() {
|
||||
);
|
||||
}
|
||||
|
||||
function Composer() {
|
||||
function Composer({ agent }: { agent?: MockupAgent }) {
|
||||
return (
|
||||
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
|
||||
<div className="mb-[16px]">
|
||||
@@ -129,8 +130,14 @@ function Composer() {
|
||||
<div className="flex items-center gap-[13px] pt-[26px]">
|
||||
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
|
||||
<span className="flex shrink-0 items-center gap-[5px]">
|
||||
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
|
||||
<span className="whitespace-nowrap text-[16px] text-mock-fg">GPT-5.6-Sol</span>
|
||||
{agent ? (
|
||||
<MockupAgentIcon agent={agent} size={19} />
|
||||
) : (
|
||||
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
|
||||
)}
|
||||
<span className="whitespace-nowrap text-[16px] text-mock-fg">
|
||||
{agent ? `${agent.name} · ${agent.model ?? "GPT-5.6-Sol"}` : "GPT-5.6-Sol"}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[16px] text-mock-fg-muted">Medium</span>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
@@ -143,7 +150,7 @@ function Composer() {
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileChat() {
|
||||
export function MobileChat({ agent }: { agent?: MockupAgent }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
@@ -151,7 +158,7 @@ export function MobileChat() {
|
||||
<div className="min-h-0 flex-1 overflow-hidden pt-[6px]">
|
||||
<Transcript />
|
||||
</div>
|
||||
<Composer />
|
||||
<Composer agent={agent} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MockupAgent } from "./agent";
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
|
||||
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
|
||||
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
|
||||
@@ -26,7 +27,7 @@ function panelKey(state: MockupStateId): string {
|
||||
return "changes";
|
||||
}
|
||||
|
||||
function CenterPane({ state }: { state: MockupStateId }) {
|
||||
function CenterPane({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
|
||||
if (state === "review") return <DiffReviewPane />;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
@@ -34,7 +35,7 @@ function CenterPane({ state }: { state: MockupStateId }) {
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
|
||||
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
|
||||
</div>
|
||||
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
|
||||
<Composer agent={agent} showProgress={state !== "extend"} busy={state !== "extend"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,7 +60,7 @@ function PanelCrossfade({ state }: { state: MockupStateId }) {
|
||||
}
|
||||
|
||||
/** The middle content column: its own header, then the tab strip, then the pane. */
|
||||
function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
function CenterColumn({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TitleBar state={state} />
|
||||
@@ -73,7 +74,7 @@ function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
exit={FADE_OUT}
|
||||
className="absolute inset-0 flex flex-col"
|
||||
>
|
||||
<CenterPane state={state} />
|
||||
<CenterPane state={state} agent={agent} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -96,7 +97,7 @@ function DockedPanel({ state }: { state: MockupStateId }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MockupWindow({ state }: { state: MockupStateId }) {
|
||||
export function MockupWindow({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
@@ -109,7 +110,7 @@ export function MockupWindow({ state }: { state: MockupStateId }) {
|
||||
header stops at the divider — it never spans over the right panel. */}
|
||||
<Sidebar state={state} />
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<CenterColumn state={state} />
|
||||
<CenterColumn state={state} agent={agent} />
|
||||
<AnimatePresence initial={false}>
|
||||
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { PiIcon } from "~/components/agent-icons";
|
||||
import type { MockupAgent } from "~/components/mockup";
|
||||
|
||||
// Source of truth for per-agent marketing landing pages.
|
||||
// To add a new agent, append an entry here and create a 4-line route file at
|
||||
// `src/routes/<slug>.tsx`. The sitemap (vite.config) reads `AGENT_PAGE_SLUGS`.
|
||||
@@ -9,6 +12,14 @@ export interface AgentPage {
|
||||
subtitle: string;
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
// Omit either field to keep the shared landing page unchanged.
|
||||
mockup?: MockupAgent;
|
||||
section?: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: readonly { title: string; description: string }[];
|
||||
link: { href: string; label: string };
|
||||
};
|
||||
}
|
||||
|
||||
export const AGENT_PAGES = [
|
||||
@@ -45,9 +56,33 @@ export const AGENT_PAGES = [
|
||||
{
|
||||
slug: "pi",
|
||||
name: "Pi Agent",
|
||||
mockup: { name: "Pi", Icon: PiIcon, model: "Sonnet 4.5" },
|
||||
section: {
|
||||
title: "Your Pi setup, with a desktop and mobile UI",
|
||||
description:
|
||||
"Paseo runs your installed Pi CLI. Start a session from the app, or import one you started in the terminal and continue from there.",
|
||||
items: [
|
||||
{
|
||||
title: "Bring your extensions",
|
||||
description:
|
||||
"Paseo supports Pi extensions including pi-subagents, pi-ask-user, pi-mcp-adapter and pi-goal-x. Follow subagents, answer questions and see task progress in the app. Extensions that depend on a custom terminal UI do not carry over.",
|
||||
},
|
||||
{
|
||||
title: "Pick from your Pi models",
|
||||
description:
|
||||
"The model picker reads the models available in your Pi setup and uses Pi’s configured model by default. Keep your provider configuration in Pi.",
|
||||
},
|
||||
{
|
||||
title: "Start with Pi on your machine",
|
||||
description:
|
||||
"Install and configure Pi on the machine where you run Paseo. Open a workspace in Paseo and choose Pi when you create an agent. Pair your phone to keep working on that machine when you step away.",
|
||||
},
|
||||
],
|
||||
link: { href: "/docs", label: "Set up Paseo" },
|
||||
},
|
||||
title: "Open source app for the Pi coding agent",
|
||||
subtitle:
|
||||
"Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
|
||||
"Use Pi from your desktop or phone, with the models and extensions you already use. Pi runs on your machine. Paseo gives you a UI for it.",
|
||||
metaTitle: "Pi Agent Mobile and Desktop App, Open Source",
|
||||
metaDescription:
|
||||
"Open source mobile and desktop app for the Pi coding agent. Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted.",
|
||||
|
||||
Reference in New Issue
Block a user