feat(website): add agent landing overlays with Pi content

This commit is contained in:
paseo-bot[bot]
2026-10-12 16:34:26 +02:00
parent 8bc5f47b1c
commit 494d88093b
10 changed files with 195 additions and 27 deletions
+69
View File
@@ -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>
+36 -1
View File
@@ -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.",