diff --git a/packages/protocol/src/plugin-registry.test.ts b/packages/protocol/src/plugin-registry.test.ts new file mode 100644 index 0000000000..5692c4c56e --- /dev/null +++ b/packages/protocol/src/plugin-registry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pluginMediaKind } from "./plugin-registry.js"; + +describe("plugin media kind", () => { + it.each([ + ["https://cdn.example.com/a/preview.png", "image"], + ["https://cdn.example.com/a/preview.JPG", "image"], + ["https://cdn.example.com/a/preview.jpeg", "image"], + ["https://cdn.example.com/a/preview.webp", "image"], + ["https://cdn.example.com/a/preview.gif", "image"], + ["https://cdn.example.com/a/demo.mp4", "video"], + ["https://cdn.example.com/a/demo.WebM", "video"], + ["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"], + ["https://cdn.example.com/mp4/preview.png?format=mp4", "image"], + ] as const)("reads %s as %s from its path extension", (url, kind) => { + expect(pluginMediaKind(url)).toBe(kind); + }); +}); diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts index 36c5354c88..90854962ba 100644 --- a/packages/protocol/src/plugin-registry.ts +++ b/packages/protocol/src/plugin-registry.ts @@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({ repository: z.object({ url: z.string().url(), commit: z.string().optional() }), artifact: PluginRegistryArtifactSchema, icon: z.string().url().optional(), - screenshots: z.array(z.string().url()), + /** Images and videos in display order; see `pluginMediaKind`. */ + media: z.array(z.string().url()), submittedAt: z.string(), reviewedAt: z.string(), updatedAt: z.string(), publishedAt: z.string(), installs: z.number().int().nonnegative().optional(), }); +const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]); + +/** Whether a registry media entry is a video or an image, from its path extension. */ +export function pluginMediaKind(url: string): "image" | "video" { + const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? ""; + return VIDEO_EXTENSIONS.has(extension) ? "video" : "image"; +} export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); export const PluginRegistryIndexSchema = z.object({ schemaVersion: z.literal(1), @@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({ z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), ), plugins: z.array(PublishedPluginSchema), + /** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */ + featured: z.array(z.string()), generatedAt: z.string(), }); export type PluginRegistryIdentity = z.infer; diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index fa49722304..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -205,19 +205,12 @@ test("loads private plugin registry settings through the configuration boundary" const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); try { const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; - await writeFile( - path.join(home, "config.json"), - JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }), - ); + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); const config = loadConfig(home, { - env: { - PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal", - PASEO_PLUGIN_REGISTRY_ENABLED: "false", - }, + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, }); expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); expect(config.pluginRegistries).toEqual(pluginRegistries); - expect(config).not.toHaveProperty("pluginRegistryEnabled"); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 24ded0dec2..79e3d97218 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -308,8 +308,6 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), pluginRegistries: PluginRegistriesSchema.optional(), - // COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06. - pluginRegistryEnabled: z.boolean().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 464ad446f0..f9592597f1 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -494,7 +494,7 @@ describe("PluginService", () => { commit: stdout.trim(), pluginPath: ".", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index ab9d6c50fb..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -433,7 +433,7 @@ describe("registry plugin sources", () => { commit, pluginPath: "packages/example", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -516,20 +516,10 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", const server = createServer((request, response) => { intents.push(request.headers["x-paseo-install"]); response.end( + // Install reads only the ID and artifact, so the document omits the directory's display fields. JSON.stringify({ id: "acme/example", - name: "Example", - description: "Test", - categories: [], - author: { github: "acme" }, - repository: { url: "https://github.com/acme/example" }, artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, - screenshots: [], - submittedAt: "2026-10-03", - reviewedAt: "2026-10-03", - updatedAt: "2026-10-03", - publishedAt: "2026-10-03", - readme: "# Example", }), ); }); diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index b57aac6c0c..009cac5a68 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -1,9 +1,17 @@ import { - PublishedPluginDetailSchema, + PluginRegistryArtifactSchema, + PluginRegistryIdSchema, type PluginRegistries, type PluginRegistryIdentity, } from "@getpaseo/protocol/plugin-registry"; import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; +import { z } from "zod"; + +// Install reads only these fields; the rest of a published plugin is the directory's display data. +const RegistryInstallDocumentSchema = z.object({ + id: PluginRegistryIdSchema, + artifact: PluginRegistryArtifactSchema, +}); export interface RegistryOptions { defaultUrl?: string; @@ -49,7 +57,7 @@ export async function resolveRegistryPlugin( } if (!response.ok) throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); - const plugin = PublishedPluginDetailSchema.parse(await response.json()); + const plugin = RegistryInstallDocumentSchema.parse(await response.json()); if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); const artifact = plugin.artifact; const target: PluginUpdateTarget = diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json index 2d23fd0343..2553fe83c6 100644 --- a/packages/website/e2e/overview.fixture.json +++ b/packages/website/e2e/overview.fixture.json @@ -18,7 +18,7 @@ "github": "security", "name": "" }, - "screenshots": [ + "media": [ "data:image/svg+xml,", "http://example.com/image.png" ], diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 246e383b57..3398a7e7d6 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -312,7 +312,7 @@ test.describe("registry fixture layout", () => { page, }) => { const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; - const source = plugin.screenshots[0]; + const source = plugin.media[0]; await openPlugins(page); await expectThumbnailCard( page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), @@ -328,6 +328,85 @@ test.describe("registry fixture layout", () => { ).toHaveAttribute("src", source); }); + test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!; + const [firstImage, video] = plugin.media; + await page.goto(`/plugins/${plugin.id}`); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + await expect(tiles).toHaveCount(3); + const layout = await tiles.evaluateAll(galleryTiles); + expect(layout.map((tile) => tile.href)).toEqual(plugin.media); + expect(new Set(layout.map((tile) => tile.size)).size).toBe(1); + const preview = tiles.nth(1).locator("video"); + await expect(preview).toHaveAttribute("preload", "metadata"); + await expect(preview).not.toHaveAttribute("controls"); + await expect(preview).not.toHaveAttribute("autoplay"); + expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true); + + const viewer = page.getByRole("dialog"); + await tiles.nth(1).click(); + const playing = viewer.getByLabel("Dracula video 2"); + await expect(playing).toHaveAttribute("src", video); + await expect(playing).toHaveAttribute("controls", ""); + await expect.poll(() => playing.evaluate(isPaused)).toBe(false); + // The fixture video is 320x240, so it has to scale up to fill the viewer. + await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + await expect(page.locator("dialog video")).toHaveCount(0); + + await tiles.first().click(); + await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute( + "src", + firstImage, + ); + await viewer.getByRole("button", { name: "Close" }).click(); + await expect(viewer).toBeHidden(); + expect(page.context().pages()).toHaveLength(1); + + const newTab = page.context().waitForEvent("page"); + await tiles.first().click({ modifiers: ["ControlOrMeta"] }); + await expect(await newTab).toHaveURL(firstImage); + await expect(viewer).toBeHidden(); + }); + + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { + const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; + await page.route(plugin.media[0], (route) => route.abort()); + await page.goto(`/plugins/${plugin.id}`); + await page.getByRole("link", { name: "launchd Jobs video 1" }).click(); + const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); + await expect(video).toBeVisible(); + await expect(video).toHaveCSS("opacity", "1"); + }); + + test("shows the plugin tile on cards when its media has no image", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); + await expect(card.locator("img, video")).toHaveCount(0); + await card.click(); + await expect( + page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), + ).toHaveAttribute( + "src", + registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0], + ); + }); + + test("features listed plugins above What's new in the registry's order", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); + const featured = page.getByRole("region", { name: "Featured" }); + await expect(featured).toContainText("Hand-picked by the maintainer."); + await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([ + /Dracula/, + /Herald/, + /launchd Jobs/, + ]); + }); + test("lists the nine categories in order with counts, and the newest plugins first", async ({ page, }) => { @@ -343,6 +422,23 @@ test.describe("registry fixture layout", () => { }); }); +function galleryTiles(links: Element[]) { + return links.map((link) => ({ + href: link.getAttribute("href"), + size: `${link.clientWidth}x${link.clientHeight}`, + })); +} + +/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ +function viewerFill(media: HTMLElement) { + const box = media.getBoundingClientRect(); + return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85)); +} + +function isPaused(video: HTMLVideoElement) { + return video.paused; +} + async function expectThumbnailCard(card: Locator, source: string, id: string) { // Card screenshots are decorative and hidden from the accessibility tree. const image = card.locator("img").first(); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 6b75599d3d..2f6560780c 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -5,6 +5,12 @@ "url": "https://plugins.paseo.sh" }, "generatedAt": "2026-10-04T15:55:47.882Z", + "featured": [ + "omercnet/dracula", + "gpambrozio/herald", + "acme/not-published", + "gpambrozio/launchd-jobs" + ], "categories": [ { "slug": "daemon-management", @@ -74,12 +80,12 @@ "name": "alhassanaraouf", "github": "alhassanaraouf" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" ], "submittedAt": "2026-09-24T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-24T17:12:05.467Z", + "publishedAt": "2026-09-24T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." @@ -106,13 +112,13 @@ "name": "Tom Lundy", "github": "dorasto" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T10:39:28.935Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" @@ -139,13 +145,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T17:46:20.500Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" @@ -172,13 +178,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T19:03:31.943Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" @@ -205,12 +211,12 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ - "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T17:56:41.061Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" @@ -237,12 +243,12 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T18:03:14.979Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" @@ -269,10 +275,10 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T22:25:58.192Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" @@ -298,13 +304,13 @@ "name": "hungcuong9125", "github": "hungcuong9125" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-30T02:57:56.473Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." @@ -331,13 +337,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:35.639Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." @@ -364,13 +370,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:29.149Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." @@ -397,13 +403,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:18.185Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." @@ -430,13 +437,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:06.720Z", + "publishedAt": "2026-09-18T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" @@ -463,13 +470,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:36.267Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." @@ -496,10 +503,10 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:04.327Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." @@ -526,13 +533,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:38:48.289Z", + "publishedAt": "2026-09-18T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." @@ -559,10 +566,10 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:15.719Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." @@ -588,12 +595,12 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T09:59:16.553Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." @@ -619,10 +626,10 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-23T10:36:50.112Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." diff --git a/packages/website/src/plugins/browse-page.tsx b/packages/website/src/plugins/browse-page.tsx index 0cfbd805db..a6d77347fe 100644 --- a/packages/website/src/plugins/browse-page.tsx +++ b/packages/website/src/plugins/browse-page.tsx @@ -5,6 +5,7 @@ import type { InstallCounts } from "./installs"; import { type BrowseQuery, browseHref } from "./links"; import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card"; import { PluginSearch } from "./plugin-search"; +import { PluginSection } from "./section"; import { addedAgo, CATEGORIES, @@ -72,36 +73,38 @@ export function BrowsePage({ )} - - {results.length === 0 && query.q && ( -
-

No plugins match.

- - Clear filters - -
- )} - {results.length === 0 && !query.q && ( -

No plugins in this category yet.

- )} - {results.length > 0 && ( -
- {results.map((plugin) => - query.sort === "new" ? ( - - ) : ( - - ), - )} -
- )} + + + {results.length === 0 && query.q && ( +
+

No plugins match.

+ + Clear filters + +
+ )} + {results.length === 0 && !query.q && ( +

No plugins in this category yet.

+ )} + {results.length > 0 && ( +
+ {results.map((plugin) => + query.sort === "new" ? ( + + ) : ( + + ), + )} +
+ )} +
@@ -186,7 +189,7 @@ function SortTabs({ query }: { query: BrowseQuery }) { ); return ( - <> +
{windowSwitch}
}
{windowSwitch &&
{windowSwitch}
} - +
); } diff --git a/packages/website/src/plugins/contribute-links.tsx b/packages/website/src/plugins/contribute-links.tsx index 476146dfca..3aeb33a16b 100644 --- a/packages/website/src/plugins/contribute-links.tsx +++ b/packages/website/src/plugins/contribute-links.tsx @@ -26,16 +26,16 @@ export function ContributeSection() { return (

Build your own plugin

-

+

Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one with the plugin docs, then submit it to get it listed here.

-
+
Read the docs diff --git a/packages/website/src/plugins/loader.ts b/packages/website/src/plugins/loader.ts index 27416a4125..ebc9bf8272 100644 --- a/packages/website/src/plugins/loader.ts +++ b/packages/website/src/plugins/loader.ts @@ -11,14 +11,19 @@ import { function registryBase(): string { return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins"; } -/** Listed plugins in index order, their install counts per window, and the server's clock. */ +/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */ export const getRegistry = createServerFn({ method: "GET" }).handler(async () => { const context = getWebsiteCacheContext(); const [index, installs] = await Promise.all([ loadRegistryIndex(registryBase(), context), loadInstallCounts(registryBase(), context), ]); - return { plugins: index.plugins, installs, now: new Date().toISOString() }; + return { + plugins: index.plugins, + featured: index.featured, + installs, + now: new Date().toISOString(), + }; }); export const getRegistryPlugin = createServerFn({ method: "GET" }) .validator((id: string) => id) diff --git a/packages/website/src/plugins/media-gallery.tsx b/packages/website/src/plugins/media-gallery.tsx new file mode 100644 index 0000000000..5fe47c4631 --- /dev/null +++ b/packages/website/src/plugins/media-gallery.tsx @@ -0,0 +1,158 @@ +import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview"; +import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry"; +import { Play, X } from "lucide-react"; +import { + type CSSProperties, + type MouseEvent, + type SyntheticEvent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +const TILE_CLASS = + "relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"; +// The viewer's room: media never exceeds this box, which leaves a margin around it. +const VIEW_WIDTH = "90vw"; +const VIEW_HEIGHT = "85vh"; +const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain"; + +interface MediaItem { + url: string; + kind: "image" | "video"; + /** Image alt text, or the video's accessible name. */ + label: string; +} + +/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */ +export function MediaGallery({ name, media }: { name: string; media: string[] }) { + const dialog = useRef(null); + const [viewing, setViewing] = useState(null); + const open = useCallback((item: MediaItem) => { + setViewing(item); + dialog.current?.showModal(); + }, []); + const close = useCallback(() => dialog.current?.close(), []); + // Unmounting the viewed media stops a playing video. + const handleClose = useCallback(() => setViewing(null), []); + const closeOnBackdrop = useCallback((event: MouseEvent) => { + if (event.target === event.currentTarget) event.currentTarget.close(); + }, []); + + if (media.length === 0) return null; + return ( + <> +
+ {media.map((url, index) => { + const source = pluginOverviewUrl(url); + if (!source) return

{url}

; + const kind = pluginMediaKind(source); + const label = `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`; + return ; + })} +
+ + {viewing?.kind === "image" && ( + {viewing.label} + )} + {viewing?.kind === "video" && } + + + + ); +} + +/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */ +function ViewerVideo({ url, label }: { url: string; label: string }) { + const [aspectRatio, setAspectRatio] = useState(null); + const readAspectRatio = useCallback((event: SyntheticEvent) => { + const { videoWidth, videoHeight } = event.currentTarget; + setAspectRatio(videoWidth / videoHeight); + }, []); + const style = useMemo( + () => + aspectRatio === null + ? undefined + : { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` }, + [aspectRatio], + ); + return ( +
-
-
-

- What’s new -

- - See all - - -
-
- {newest.map((plugin) => ( - - ))} -
-
+
+ {featured.length > 0 && ( + +
+ Featured +

Hand-picked by the maintainer.

+
+
+ {featured.map((plugin) => ( + + ))} +
+
+ )} -
-

- Categories -

-
- {CATEGORIES.map((category) => { - const Icon = CATEGORY_ICONS[category.slug]; - return ( - - - - {category.label} - - - {getPluginsInCategory(plugins, category.slug).length} - - - ); - })} -
-
- -
- -
- {top.map((plugin, index) => ( - - ))} -
-
+ +
+ {newest.map((plugin) => ( + + ))} +
+ + + + Categories +
+ {CATEGORIES.map((category) => { + const Icon = CATEGORY_ICONS[category.slug]; + return ( + + + + {category.label} + + + {getPluginsInCategory(plugins, category.slug).length} + + + ); + })} +
+
+ + + + + + Most installed + + + + + +
+ {top.map((plugin, index) => ( + + ))} +
+
+