Homepage shows an agent building a plugin live (#8515)

* feat(website): show agents building plugins on the homepage

The plugins section now leads with the workflow instead of the API list:
you ask an agent for a panel and it appears, without a restart. It plays
as a timeline (prompt, tool calls, result) and then slides in the panel
the agent built, followed by the 400+ community plugins and their
categories. It moves above the automation section.

Mock tool calls on the homepage now match the app's collapsed timeline
rows: same labels and icons, muted text in the UI font, and Paseo MCP
tools named by action ("Create agent"). The hero pills are now nouns.

* fix(website): show the finished plugin demo when motion is reduced

The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup.
This commit is contained in:
Mohamed Boudra
2026-11-02 10:50:12 +07:00
committed by GitHub
parent e2f0779379
commit b474beedce
6 changed files with 315 additions and 123 deletions
+40 -1
View File
@@ -6,7 +6,7 @@ const desktopName =
async function openHomepage(page: Page) {
await page.setViewportSize({ width: 1512, height: 930 });
await page.goto("/");
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
}
async function viewPhones(page: Page) {
@@ -59,3 +59,42 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
await resizeToPhone(page);
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
});
/** Opacity as rendered: the element's own times every ancestor's. */
async function renderedOpacity(locator: Locator) {
return locator.evaluate((element) => {
let opacity = 1;
for (let node: Element | null = element; node; node = node.parentElement) {
opacity *= Number.parseFloat(getComputedStyle(node).opacity);
}
return opacity;
});
}
test("the plugin demo plays through to the panel the agent built", async ({ page }) => {
await openHomepage(page);
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
expect(await renderedOpacity(builtPanelRow)).toBe(0);
// Hydration can reset the scroll position, so keep the demo in view until it finishes.
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
await expect(async () => {
await heading.scrollIntoViewIfNeeded();
expect(await renderedOpacity(builtPanelRow)).toBe(1);
}).toPass({ timeout: 15_000 });
expect(await renderedOpacity(page.getByText("Review queue is in your sidebar."))).toBe(1);
});
test("the plugin demo shows its finished state when motion is reduced", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openHomepage(page);
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
const panel = builtPanelRow.locator("xpath=ancestor::div[contains(@class, 'md:border-l')]");
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
await expect(async () => {
await heading.scrollIntoViewIfNeeded();
expect(await renderedOpacity(builtPanelRow)).toBe(1);
}).toPass({ timeout: 2_000 });
expect(await panel.evaluate((element) => getComputedStyle(element).transform)).toBe("none");
});
+209 -47
View File
@@ -1,4 +1,6 @@
import { PLUGINS_LINKED } from "~/plugins/launch";
import { categoryHref } from "~/plugins/links";
import type { CategorySlug } from "~/plugins/categories";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import {
@@ -10,7 +12,7 @@ import {
ExternalLink,
Gem,
GitFork,
LayoutPanelLeft,
GitPullRequest,
Laptop,
Lock,
Merge,
@@ -19,16 +21,17 @@ import {
Smartphone,
Terminal,
Users,
Workflow,
type LucideIcon,
} from "lucide-react";
import {
motion,
AnimatePresence,
useInView,
useReducedMotion,
useScroll,
useTransform,
type Transition,
type Variants,
} from "framer-motion";
// Shared motion presets — hoisted so every JSX site receives the same object
@@ -86,7 +89,14 @@ import {
PiIcon,
} from "~/components/agent-icons";
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
import {
MobileChat,
MobileDiff,
MobileSidebar,
PhoneFrame,
type ToolCall,
ToolCallRow,
} from "~/components/mockup";
import { FAQItem } from "~/components/faq-item";
import { SiteFooter } from "~/components/site-footer";
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
@@ -132,8 +142,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<SocialProofWall />
<MultiProviderSection />
<TurnkeySection />
<AutomationSection />
<ExtensibleSection />
<AutomationSection />
<SponsorsSection placement="page" />
<SponsorSection />
<PhilosophySection />
@@ -703,41 +713,50 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
);
}
const MCP_AGENT_CALLS: ToolCall[] = [
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
];
function McpAutomationTranscript() {
const { t } = useTranslation();
return (
<AgentTurn request={t("landing.automation.transcriptRequest")}>
<AgentText>{t("landing.automation.transcriptPlan")}</AgentText>
<ToolCalls calls={MCP_AGENT_CALLS} />
<AgentText>{t("landing.automation.transcriptDone")}</AgentText>
</AgentTurn>
);
}
/** A user message and the agent's reply, laid out like the app's timeline. */
function AgentTurn({ request, children }: { request: string; children: React.ReactNode }) {
return (
<div className="space-y-5">
<div className="ml-auto w-fit max-w-xl rounded-xl rounded-tr-none bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/75">
{t("landing.automation.transcriptRequest")}
</p>
</div>
<div className="flex items-start gap-3">
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
<div className="min-w-0 flex-1 space-y-4">
<p className="text-sm leading-relaxed text-white/55">
{t("landing.automation.transcriptPlan")}
</p>
<div className="space-y-2 font-mono text-[11px]">
<McpAgentCall issue="#412" provider="claude/opus-4.6" />
<McpAgentCall issue="#417" provider="codex/gpt-5.6-sol" />
</div>
<p className="text-sm leading-relaxed text-white/55">
{t("landing.automation.transcriptDone")}
</p>
</div>
</div>
<UserMessage>{request}</UserMessage>
<div className="space-y-3">{children}</div>
</div>
);
}
function McpAgentCall({ issue, provider }: { issue: string; provider: string }) {
function UserMessage({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-white/[0.07] bg-white/[0.025] px-3 py-2">
<span className="text-sky-300/80">create_agent</span>
<span className="text-white/25">{issue}</span>
<span className="text-white/35">{provider}</span>
<span className="text-white/25">worktree</span>
<div className="ml-auto w-fit max-w-md rounded-xl bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/80">{children}</p>
</div>
);
}
function AgentText({ children }: { children: React.ReactNode }) {
return <p className="text-sm leading-relaxed text-white/80">{children}</p>;
}
function ToolCalls({ calls }: { calls: ReadonlyArray<ToolCall> }) {
return (
<div>
{calls.map((call) => (
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
))}
</div>
);
}
@@ -842,17 +861,16 @@ function ExtensibleSection() {
title={t("landing.plugins.title")}
description={t("landing.plugins.description")}
>
<div className="space-y-8">
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
<div className="grid gap-4 md:grid-cols-3">
{PLUGIN_CAPABILITIES.map((capability) => (
<FeatureCard
key={capability.key}
icon={capability.icon}
title={t(`landing.plugins.${capability.key}Title`)}
description={t(`landing.plugins.${capability.key}Description`)}
/>
))}
<div className="space-y-12">
<SelfModifyDemo />
<div className="space-y-6">
<ProductLead
icon={Puzzle}
title={t("landing.plugins.libraryTitle")}
description={t("landing.plugins.libraryDescription")}
actions={pluginActions}
/>
<PluginCategoryLinks />
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/45">{t("landing.plugins.license")}</p>
@@ -872,6 +890,156 @@ function ExtensibleSection() {
);
}
const PLUGIN_BUILD_CALLS: ToolCall[] = [
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
{ kind: "shell", detail: "paseo plugin init review-queue" },
{ kind: "write", detail: "review-queue/index.ts" },
{ kind: "write", detail: "review-queue/review-queue.client.tsx" },
{ kind: "shell", detail: "paseo plugin install ./review-queue" },
];
// The demo plays once when it scrolls into view: the prompt, then each timeline
// item in turn, then the panel the agent built slides in and fills.
const DEMO_STEP_SECONDS = 0.45;
const DEMO_STEPS = {
prompt: 0,
plan: 1.4,
firstCall: 2.2,
} as const;
const DEMO_DONE_STEP = DEMO_STEPS.firstCall + PLUGIN_BUILD_CALLS.length;
const DEMO_PANEL_STEP = DEMO_DONE_STEP + 1;
const DEMO_ITEM_VARIANTS: Variants = {
hidden: { opacity: 0, y: 6 },
shown: (step: number) => ({
opacity: 1,
y: 0,
transition: { delay: step * DEMO_STEP_SECONDS, duration: 0.35, ease: "easeOut" },
}),
};
const DEMO_PANEL_VARIANTS: Variants = {
hidden: { opacity: 0, x: "100%" },
shown: {
opacity: 1,
x: 0,
transition: {
delay: DEMO_PANEL_STEP * DEMO_STEP_SECONDS,
duration: 0.6,
ease: [0.22, 0.61, 0.36, 1],
},
},
};
/** One piece of the demo that fades in at its step. */
function DemoItem({ step, children }: { step: number; children: React.ReactNode }) {
return (
<motion.div variants={DEMO_ITEM_VARIANTS} custom={step}>
{children}
</motion.div>
);
}
/** The ask on the left, the panel it produced on the right. */
function SelfModifyDemo() {
const { t } = useTranslation();
// Visitors who ask for reduced motion get the finished demo, with no slide or staging.
const reduceMotion = useReducedMotion();
return (
<motion.div
initial={reduceMotion ? "shown" : "hidden"}
whileInView="shown"
viewport={VIEWPORT_60}
className="grid min-h-[22rem] grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-white/10 bg-black/20 md:grid-cols-[minmax(0,1fr)_17rem]"
>
<div className="space-y-5 p-5 md:p-6">
<DemoItem step={DEMO_STEPS.prompt}>
<UserMessage>{t("landing.plugins.request")}</UserMessage>
</DemoItem>
<div className="space-y-3">
<DemoItem step={DEMO_STEPS.plan}>
<AgentText>{t("landing.plugins.plan")}</AgentText>
</DemoItem>
<div>
{PLUGIN_BUILD_CALLS.map((call, index) => (
<DemoItem key={`${call.kind}:${call.detail}`} step={DEMO_STEPS.firstCall + index}>
<ToolCallRow call={call} />
</DemoItem>
))}
</div>
<DemoItem step={DEMO_DONE_STEP}>
<AgentText>{t("landing.plugins.done")}</AgentText>
</DemoItem>
</div>
</div>
<ReviewQueuePreview />
</motion.div>
);
}
const REVIEW_QUEUE = [
{ number: "#418", title: "Retry flaky relay handshake", author: "ana", age: "12m" },
{ number: "#415", title: "Group schedules by project", author: "kenji", age: "1h" },
{ number: "#409", title: "Add Gruvbox theme", author: "lea", age: "3h" },
] as const;
/** The panel the agent just built, docked like a real sidebar panel. */
function ReviewQueuePreview() {
const { t } = useTranslation();
return (
<motion.div
variants={DEMO_PANEL_VARIANTS}
className="border-t border-white/10 bg-white/[0.025] p-4 md:border-t-0 md:border-l"
>
<div className="mb-3 flex items-center gap-2">
<GitPullRequest className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium text-white/85">{t("landing.plugins.panelTitle")}</span>
<span className="ml-auto rounded-full bg-white/[0.07] px-2 py-0.5 text-[10px] text-white/55">
{REVIEW_QUEUE.length}
</span>
</div>
<div className="space-y-1">
{REVIEW_QUEUE.map((pr, index) => (
<DemoItem key={pr.number} step={DEMO_PANEL_STEP + 1.2 + index * 0.4}>
<div className="rounded-md px-2 py-2 hover:bg-white/[0.04]">
<p className="truncate text-xs text-white/80">{pr.title}</p>
<p className="mt-0.5 font-mono text-[10px] text-white/35">
{pr.number} · {pr.author} · {pr.age}
</p>
</div>
</DemoItem>
))}
</div>
</motion.div>
);
}
const SHOWCASE_CATEGORIES = [
"themes",
"sidebar",
"workspaces",
"providers",
"orchestration",
"git",
] as const satisfies ReadonlyArray<CategorySlug>;
function PluginCategoryLinks() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<div className="flex flex-wrap gap-2">
{SHOWCASE_CATEGORIES.map((category) => (
<a
key={category}
href={localize(categoryHref(category))}
className="rounded-full border border-white/10 bg-white/[0.025] px-3 py-1.5 text-xs text-white/70 transition-colors hover:border-white/15 hover:bg-white/[0.06] hover:text-white"
>
{t(`plugins.categories.${category}.label`)}
</a>
))}
</div>
);
}
const PLUGINS_BROWSE = PLUGINS_LINKED
? { href: "/plugins", external: false }
: { href: "https://paseo.cafe", external: true };
@@ -899,12 +1067,6 @@ function PluginActions() {
);
}
const PLUGIN_CAPABILITIES = [
{ icon: LayoutPanelLeft, key: "ui" },
{ icon: Workflow, key: "lifecycle" },
{ icon: Bot, key: "providers" },
] as const;
const PRINCIPLES = [
{ icon: Lock, key: "private" },
{ icon: Compass, key: "independent" },
+39 -47
View File
@@ -1,14 +1,14 @@
import {
AudioLines,
BookOpen,
Brain,
ChevronDown,
CircleDot,
Copy,
FilePenLine,
Eye,
LoaderCircle,
ListTodo,
Mic,
Pencil,
Plus,
ShieldOff,
Split,
@@ -18,7 +18,7 @@ import {
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { DiffStat } from "./atoms";
import { CodexIcon } from "./icons";
import { CodexIcon, PaseoMark } from "./icons";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const MODEL_ICON = <CodexIcon size={13} />;
@@ -94,70 +94,62 @@ export function AgentTranscript() {
);
}
interface PluginToolCall {
kind: "read" | "shell" | "write";
label: string;
detail: string;
/**
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
* fixed label; Paseo MCP tools name their action, such as "Create agent".
*/
export type ToolCall =
| { kind: "read" | "write" | "shell"; detail: string }
| { kind: "paseo"; label: string; detail: string };
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
function toolCallLabel(call: ToolCall): string {
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
}
const PLUGIN_TOOL_CALLS: PluginToolCall[] = [
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
{
kind: "shell",
label: "Shell",
detail: "paseo plugin init ~/dev/release-radar",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/index.ts",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/release-radar.client.tsx",
},
{
kind: "shell",
label: "Shell",
detail: "npm install && npm run typecheck",
},
{
kind: "shell",
label: "Shell",
detail: "paseo plugin install ~/dev/release-radar",
},
const PLUGIN_TOOL_CALLS: ToolCall[] = [
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
{ kind: "shell", detail: "npm install && npm run typecheck" },
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
];
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
if (kind === "read") return <BookOpen size={14} />;
if (kind === "write") return <FilePenLine size={14} />;
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
if (kind === "read") return <Eye size={14} />;
if (kind === "write") return <Pencil size={14} />;
if (kind === "paseo") return <PaseoMark size={14} />;
return <SquareTerminal size={14} />;
}
function PluginToolRow({ call }: { call: PluginToolCall }) {
/**
* One tool call row, drawn like the app's collapsed timeline item
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
* all muted and in the UI font.
*/
export function ToolCallRow({ call }: { call: ToolCall }) {
return (
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
<span className="text-mock-fg-muted">
<PluginToolIcon kind={call.kind} />
</span>
<span className="shrink-0 text-mock-fg">{call.label}</span>
<span className="min-w-0 truncate font-mono text-[11.5px] text-mock-fg-muted">
{call.detail}
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
<span className="shrink-0">
<ToolCallIcon kind={call.kind} />
</span>
<span className="shrink-0">{toolCallLabel(call)}</span>
<span className="min-w-0 truncate">{call.detail}</span>
</div>
);
}
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
return (
<div>
{calls.map((call) => (
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
))}
</div>
);
@@ -1,4 +1,5 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
export { type ToolCall, ToolCallRow } from "./chat";
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
export { PhoneFrame } from "./mobile/frame";
export { MobileChat } from "./mobile/chat";
+13 -14
View File
@@ -375,19 +375,18 @@
"otherHostComment": "# Target another host"
},
"plugins": {
"title": "Extend it with plugins",
"description": "Add new panels and functionality that work in desktop, web and mobile",
"lead": "Plugins",
"title": "Ask Paseo to change Paseo",
"description": "Agents in Paseo can change the whole UI without a restart, even on mobile.",
"request": "Add a sidebar panel with the PRs waiting on my review.",
"plan": "I'll build it as a local plugin and install it.",
"done": "Review queue is in your sidebar.",
"panelTitle": "Review queue",
"libraryTitle": "400+ community plugins",
"libraryDescription": "Built by developers on the same plugin API your agents use",
"browse": "Browse plugins",
"docs": "Plugin docs",
"license": "Paseo is Apache 2.0.",
"fork": "Fork the repo",
"uiTitle": "UI",
"uiDescription": "Screens, workspace panels, sidebar items, slash commands, and themes",
"lifecycleTitle": "Agent lifecycle",
"lifecycleDescription": "Change config and MCP servers, answer permissions, and follow up",
"providersTitle": "Providers",
"providersDescription": "Add a coding agent as a provider, used like the built-in ones"
"fork": "Fork the repo"
},
"philosophy": {
"title": "Philosophy",
@@ -447,10 +446,10 @@
},
"mockup": {
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
"build": "Build",
"review": "Review",
"ship": "Ship",
"extend": "Extend",
"build": "Parallel agents",
"review": "Code review",
"ship": "Pull requests",
"extend": "Plugins",
"app": {
"newWorkspace": "New workspace",
"history": "History",
+13 -14
View File
@@ -375,19 +375,18 @@
"otherHostComment": "# 指定另一台主机"
},
"plugins": {
"title": "用插件扩展",
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
"lead": "插件",
"title": "让 Paseo 修改 Paseo",
"description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。",
"request": "在侧边栏加一个面板,列出等待我审查的 PR。",
"plan": "我会把它做成本地插件并安装。",
"done": "审查队列已出现在你的侧边栏。",
"panelTitle": "审查队列",
"libraryTitle": "400 多个社区插件",
"libraryDescription": "开发者用的是和你的 Agent 相同的插件 API",
"browse": "浏览插件",
"docs": "插件文档",
"license": "Paseo 采用 Apache 2.0 许可证。",
"fork": "Fork 仓库",
"uiTitle": "界面",
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
"lifecycleTitle": "Agent 生命周期",
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
"providersTitle": "Provider",
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
"fork": "Fork 仓库"
},
"philosophy": {
"title": "理念",
@@ -447,10 +446,10 @@
},
"mockup": {
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
"build": "构建",
"review": "审查",
"ship": "发布",
"extend": "扩展",
"build": "并行 Agent",
"review": "代码审查",
"ship": "Pull Request",
"extend": "插件",
"app": {
"newWorkspace": "新建工作区",
"history": "历史",