Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 802359152e test(app): retain Android workspace memory diagnostic
Preserve the device assertion for a supplemental LOW_MEMORY exit seen with 800 projects. This does not establish the cause of the cold-start ANR reported in #4408 and changes no production behavior.
2026-10-13 00:41:58 +02:00
paseo-bot[bot] 196dc3acd3 fix(pi): update thinking levels when extensions change them (#6741)
Publish the existing thinking-option event as soon as Pi changes its effective level, keeping subscribers and persistence in sync before the next state refresh.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 23:56:31 +02:00
e867773932 Restore native app startup (#6734)
* fix(app): install navigator polyfill before any app module loads

@xterm/headless reads navigator.userAgent at module evaluation. Since
#6455 the root layout transitively imports it, so the layout's
module-level polyfillNavigator() ran too late and native builds crashed
at launch with "Cannot read property 'includes' of undefined".

Install the crypto, navigator and screen.orientation polyfills from a
side-effect module imported first by index.ts. Calls written in index.ts
itself ran after all hoisted imports had evaluated.

Fixes #6504

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): isolate native startup regression from shared globals

Exercise the real navigator polyfill and terminal dependency in fresh VM contexts, including the pre-initialization failure. Preserve a check that entry imports install navigator before the app loads.

Co-authored-by: panrafal <rl@stamina.pl>

* test(app): follow startup import lint rule

---------

Co-authored-by: panrafal <rl@stamina.pl>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 22:59:25 +02:00
Sevn ChenandChen-DaYi bfe38d92f6 fix(pi): mark turn-limited tintinweb subagents as completed (#6594)
Co-authored-by: Chen-DaYi <12897415+Chen-DaYi@users.noreply.github.com>
2026-10-12 22:01:25 +02:00
paseo-bot[bot]andmistop 9551b2cc11 Open loaded Linux desktop windows when hidden rendering stalls (#6726)
* fix(desktop): show the window when ready-to-show never fires on Linux

Reveal hidden Linux windows that do not receive an initial compositor frame.

* fix(desktop): open loaded Linux windows without waiting for a frame

A hidden Linux window can finish loading without painting, so waiting only for ready-to-show leaves the application invisible. Reveal the loaded document and retain the frame-ready path on other platforms.

Co-authored-by: mistop <jhlim@mistop.org>

---------

Co-authored-by: mistop <jhlim@mistop.org>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:05:04 +02:00
Elowen 6f62125ee0 fix: return dot-prefixed paths in workspace suggestions (#6720)
* fix(paths): allow double-dot names in contained paths

* test(paths): cover dot-prefixed workspace suggestions
2026-10-12 17:14:54 +02:00
Mohamed Boudraandpaseo-bot[bot] 8bc5f47b1c Add a Report a bug button to plugin detail pages (#6721)
Links to a new issue on the plugin's GitHub repository, from the registry's repository URL. Monorepo folder URLs resolve to the repository; non-GitHub sources show no button.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 20:51:22 +07:00
Mohamed Boudraandpaseo-bot[bot] e1b01df905 Keep gallery cards inside their grid column on phones (#6722)
A card whose description has an unbreakable word, such as xpufx/slash's raw
README HTML, sized its grid column to that word and widened browse pages to
564px at a 390px viewport. The card now has min-width 0 and its description
wraps long words.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 20:22:34 +07:00
Mohamed Boudraandpaseo-bot[bot] 7321d8a488 List new plugins by first listing, add Recently updated, show authors on grid cards (#6718)
* List new plugins by first listing, add Recently updated, and show authors on grid cards

What's new and the Newest tab sort by publishedAt, which the registry now keeps
at the first listing. Recently updated lists plugins whose current version was
approved after their first listing, on the directory and as a browse tab. Browse
and author grid cards show the author's avatar and name like the directory cards.

* Rename the card image helper so it does not shadow a test parameter

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 19:47:02 +07:00
paseo-bot[bot]andTeraYang 283a985fb6 fix(app): keep Android status icons readable as themes change (#6707)
* fix(app): sync Android system bar icon style with app theme

With edge-to-edge enabled the app draws under the Android system bars,
but the bars keep their default light icon style regardless of theme,
so the status bar clock/battery/icons are invisible on light themes
(white on white). Call SystemBars.setStyle from the appearance provider
whenever the theme is applied: dark icons on light themes, light icons
on dark themes, auto following the system for adaptive themes and
plugin-contributed themes.

Refs #1272 (runtime only; the splash screen phase is not covered).

* fix(app): keep Android status icons readable as themes change

Keep system-bar contrast subscribed to the resolved app theme so automatic appearance changes update the icons as well as the app surface. Add a native regression covering explicit and automatic themes.

Co-authored-by: TeraYang <4110825+terayang@users.noreply.github.com>

* fix(app): map system-bar appearance through color scheme

Unistyles reserves style mappings for React Native style objects. Keep SystemBars style selection inside its appearance adapter and map the colorScheme prop instead.

---------

Co-authored-by: TeraYang <4110825+terayang@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 14:04:41 +02:00
Mohamed Boudraandpaseo-bot[bot] 8d03ccfbf0 Sort What's new plugins by submission time (#6713)
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:47:48 +07:00
36 changed files with 1015 additions and 135 deletions
+16
View File
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Desktop Linux hidden-window fallback
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
forever without a single compositor frame: the renderer loads and runs, but
`ready-to-show` never fires, so the window is never shown and the app looks like
it did not start while every process is alive. `showWindowWhenReady`
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
the document finishes loading if the event has not arrived. The fallback is
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
Run the startup regression on Linux with an isolated display:
```bash
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
```
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -0,0 +1,62 @@
// Open Settings > Appearance in a native Android app loaded from this checkout.
// AGENT_DEVICE_STATE_DIR=<state-dir> node packages/app/e2e/mobile/system-bars/android.mjs <session> <adb-serial> <app-id>
// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, serial, appId] = process.argv;
assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
function windowState() {
const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
assert(regions, "Android must report the status-bar appearance region");
return regions.includes("LIGHT_STATUS_BARS");
}
async function expectDarkIcons(expected, context) {
const deadline = Date.now() + 5_000;
do {
if (windowState() === expected) {
console.log(`PASS: ${context}`);
return;
}
await delay(100);
} while (Date.now() < deadline);
assert.equal(windowState(), expected, context);
}
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
selectTheme("Light");
await expectDarkIcons(true, "Light app theme uses dark status icons");
selectTheme("Dark");
await expectDarkIcons(false, "Dark app theme uses light status icons");
selectTheme("System");
await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
ad("settings", "appearance", "dark");
await expectDarkIcons(
false,
"System theme follows an OS change to dark without reopening settings",
);
ad("settings", "appearance", "light");
await expectDarkIcons(true, "System theme follows an OS change back to light");
@@ -0,0 +1,21 @@
# Android workspace memory diagnostic
Supplemental reproduction for [#4408](https://github.com/getpaseo/paseo/issues/4408), using the production 0.11.2 APK on an Android 16 emulator. This tests an adapted failure: opening the sidebar with 800 projects, each containing one workspace, produced a `LOW_MEMORY` exit after 66–90 seconds. It does not reproduce the reported 18-second cold-start ANR or establish its cause.
Before running:
- Use a dedicated emulator with the production APK installed and notifications permission settled. Bind the agent-device session to that emulator before running.
- Pair the app with an isolated daemon containing 800 active workspaces, each in its own project. Use Project grouping and leave projects expanded. The observed fixture used existing directories inside a Git checkout, with workspace titles permuted by `(index * 317) % 800`, no agents, and no terminals.
- Record the daemon revision, emulator RAM and Java heap settings, and the project/workspace distribution. Device memory pressure controls whether Android kills the app.
Run from the repository root with the agent-device session, Android serial, and an artifact directory:
```sh
node packages/app/e2e/mobile/workspace-memory/android.mjs workspace-memory emulator-5556 .dev/workspace-memory
```
The test cold-starts the paired app, opens its sidebar, samples memory and the process ID every five seconds for two minutes, and verifies that closing the sidebar responds. A process exit or restart fails the assertion. Memory, exit records, and logs stay in the supplied artifact directory.
Control: repeat with 800 workspaces grouped into one project. That distribution survived the two-minute automated check and responded to closing the sidebar, with total PSS near 300 MB. The project-per-workspace fixture reached about 2.5 GB PSS before Android recorded `LOW_MEMORY`; its Java heap rose and fell rather than showing the reporter's full-heap ANR. Neither result proves that shared collators would fix the report.
This diagnostic is deliberately outside CI and contains no production change.
@@ -0,0 +1,65 @@
// Requires a paired production app and the workspace fixture described in README.md.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, serial, artifacts] = process.argv;
assert(session && serial && artifacts, "Pass session, Android serial, and artifact directory");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
function adb(...args) {
return execFileSync("adb", ["-s", serial, ...args], {
encoding: "utf8",
timeout: 15_000,
});
}
function pid() {
try {
return adb("shell", "pidof", "sh.paseo").trim();
} catch {
return "";
}
}
ad("open", "sh.paseo", "--relaunch", "--platform", "android");
ad("wait", "text", "Open menu", "15000");
assert.equal(
JSON.parse(ad("perf", "memory", "sample", "--json")).data.deviceId,
serial,
"Session and adb serial must address the same device",
);
const initialPid = pid();
assert(initialPid, "App must have a process before opening the sidebar");
ad("press", 'label="Open menu"', "--settle");
const startedAt = Date.now();
const samples = [];
try {
while (Date.now() - startedAt < 120_000) {
const currentPid = pid();
samples.push({
elapsedMs: Date.now() - startedAt,
pid: currentPid,
meminfo: currentPid ? adb("shell", "dumpsys", "meminfo", "sh.paseo") : "",
});
writeFileSync(join(artifacts, "samples.json"), JSON.stringify(samples, null, 2));
assert.equal(currentPid, initialPid, "Opening the workspace sidebar must keep the app alive");
await delay(5_000);
}
ad("press", 'label="Close sidebar"', "--settle");
ad("wait", "text", "Open menu", "10000");
console.log("PASS: app survived two minutes with the sidebar open and responded to Close");
} finally {
writeFileSync(
join(artifacts, "exit-info.txt"),
adb("shell", "dumpsys", "activity", "exit-info", "sh.paseo"),
);
writeFileSync(join(artifacts, "logcat.txt"), adb("logcat", "-d", "-t", "4000"));
}
+3 -7
View File
@@ -1,10 +1,6 @@
// Polyfill crypto.randomUUID for React Native before any other imports
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Install runtime polyfills before any other module evaluates. Keep this the first import.
// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
import "./src/polyfills/install-early";
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
-2
View File
@@ -103,7 +103,6 @@ import {
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -139,7 +138,6 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
+5 -1
View File
@@ -21,6 +21,7 @@ import {
} from "@/plugins/themes";
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
import { applyAppearance } from "./apply";
import { AppearanceSystemBars } from "./system-bars";
interface ContributedThemes {
options: PluginThemeOption[];
@@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
if (!hasAppliedAppearance) return null;
return (
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
<ContributedThemesContext.Provider value={value}>
<AppearanceSystemBars />
{children}
</ContributedThemesContext.Provider>
);
}
@@ -0,0 +1,11 @@
import { SystemBars } from "react-native-edge-to-edge";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
function ThemeSystemBars({ colorScheme }: Pick<Theme, "colorScheme">) {
return <SystemBars style={colorScheme === "light" ? "dark" : "light"} />;
}
export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({
colorScheme: theme.colorScheme,
}));
@@ -0,0 +1,3 @@
export function AppearanceSystemBars() {
return null;
}
@@ -0,0 +1,12 @@
// Side-effect module imported first by index.ts. Babel hoists imports above
// statements, so polyfill calls written in index.ts would run only after every
// imported module had evaluated. Modules that read these globals at load time,
// such as @xterm/headless reading navigator.userAgent, need them installed here.
import { polyfillCrypto } from "./crypto";
import { polyfillNavigator } from "./navigator";
import { polyfillScreenOrientation } from "./screen-orientation";
polyfillCrypto();
polyfillNavigator();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
polyfillScreenOrientation();
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { createContext, runInContext } from "node:vm";
import ts from "typescript";
import { expect, it } from "vitest";
it("installs navigator before the app entry loads its other dependencies", () => {
const entry = ts.createSourceFile(
"index.ts",
readFileSync(new URL("../../index.ts", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
);
const firstImport = entry.statements.find(ts.isImportDeclaration);
expect(firstImport?.moduleSpecifier.getText(entry)).toBe('"./src/polyfills/install-early"');
const early = ts.createSourceFile(
"install-early.ts",
readFileSync(new URL("./install-early.ts", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
);
expect(
early.statements.some(
(statement) =>
ts.isExpressionStatement(statement) &&
ts.isCallExpression(statement.expression) &&
statement.expression.expression.getText(early) === "polyfillNavigator",
),
).toBe(true);
});
it("initializes the native navigator before evaluating the real terminal dependency", () => {
const require = createRequire(import.meta.url);
const terminalSource = readFileSync(require.resolve("@xterm/headless"), "utf8");
const navigatorSource = ts.transpileModule(
readFileSync(new URL("./navigator.ts", import.meta.url), "utf8"),
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } },
).outputText;
// Each VM owns its globals, so the native navigator shape never replaces
// globals in the test runner or needs a native-module mock.
const uninitialized = createContext({
navigator: {},
window: {},
module: { exports: {} },
exports: {},
});
expect(() => runInContext(terminalSource, uninitialized)).toThrow(/includes/);
const initialized = createContext({
navigator: {},
window: {},
module: { exports: {} },
exports: {},
});
runInContext(navigatorSource, initialized);
runInContext("exports.polyfillNavigator()", initialized);
expect(() => runInContext(terminalSource, initialized)).not.toThrow();
});
@@ -0,0 +1,48 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { _electron as electron, expect } from "playwright/test";
const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-"));
const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url));
async function launchWindowWithoutHiddenFrames() {
const main = path.join(root, "main.cjs");
await writeFile(
main,
`const { app, BrowserWindow } = require("electron");
const { showWindowWhenReady } = require(${JSON.stringify(modulePath)});
app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))});
app.whenReady().then(async () => {
const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false });
showWindowWhenReady(win, { showOnLoad: true });
await win.loadURL("data:text/html,<h1>Desktop ready</h1>");
global.visibleAfterLoad = win.isVisible();
});`,
);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"),
);
const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : [];
return electron.launch({
args: ["--no-sandbox", ...displayArgs, main],
env,
});
}
async function expectLoadedDesktopIsShown(desktop) {
await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true);
const page = await desktop.firstWindow();
await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible();
}
let desktop;
try {
desktop = await launchWindowWithoutHiddenFrames();
await expectLoadedDesktopIsShown(desktop);
console.log("Loaded desktop is visible without hidden compositor frames");
} finally {
await desktop?.close();
await rm(root, { recursive: true, force: true });
}
+2 -1
View File
@@ -25,7 +25,8 @@
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
"test": "install-electron && vitest run --exclude \"e2e/**\"",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs",
"test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
},
"dependencies": {
"@getpaseo/cli": "*",
+3 -2
View File
@@ -43,6 +43,7 @@ import {
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
import { showWindowWhenReady } from "./window/show-when-ready.js";
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
@@ -773,8 +774,8 @@ async function createWindow(
registerBrowserWebviewNavigationGuards(contents);
});
mainWindow.once("ready-to-show", () => {
mainWindow.show();
showWindowWhenReady(mainWindow, {
showOnLoad: process.platform === "linux",
});
if (!app.isPackaged) {
@@ -0,0 +1,57 @@
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { showWindowWhenReady } from "./show-when-ready.js";
class Window extends EventEmitter {
webContents = new EventEmitter();
shows = 0;
destroyed = false;
show() {
this.shows += 1;
}
isDestroyed() {
return this.destroyed;
}
close() {
this.destroyed = true;
this.emit("closed");
}
}
describe("showWindowWhenReady", () => {
it("shows a loaded Linux window even when no initial frame was painted", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: true });
expect(win.shows).toBe(0);
win.webContents.emit("did-finish-load");
expect(win.shows).toBe(1);
win.emit("ready-to-show");
expect(win.shows).toBe(1);
});
it("shows on the first frame without showing again after load", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: true });
win.emit("ready-to-show");
win.webContents.emit("did-finish-load");
expect(win.shows).toBe(1);
});
it("preserves waiting for the initial frame when show on load is disabled", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: false });
win.webContents.emit("did-finish-load");
expect(win.shows).toBe(0);
win.emit("ready-to-show");
expect(win.shows).toBe(1);
});
it("keeps a closed window closed when readiness arrives later", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: true });
win.close();
win.webContents.emit("did-finish-load");
win.emit("ready-to-show");
expect(win.shows).toBe(0);
});
});
@@ -0,0 +1,25 @@
interface ReadyToShowWindow {
once(event: "ready-to-show", listener: () => void): unknown;
webContents: {
once(event: "did-finish-load", listener: () => void): unknown;
};
show(): void;
isDestroyed(): boolean;
}
export function showWindowWhenReady(
win: ReadyToShowWindow,
options: { showOnLoad: boolean },
): void {
let shown = false;
const show = () => {
if (shown || win.isDestroyed()) return;
shown = true;
win.show();
};
win.once("ready-to-show", show);
// On Linux a hidden window can wait for compositor frames until it is shown.
// Loading the document is sufficient to reveal it without depending on a frame.
if (options.showOnLoad) win.webContents.once("did-finish-load", show);
}
@@ -1943,6 +1943,48 @@ describe("PiRpcAgentSession", () => {
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off");
});
test.each(["high", "off", "max"] as const)(
"publishes an extension's thinking change to %s before refreshing state",
async (level) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const changes: AgentStreamEvent[] = [];
session.subscribe((event) => {
if (event.type === "thinking_option_changed") {
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
changes.push(event);
}
});
const runtime = pi.latestSession();
runtime.state = { ...runtime.state, thinkingLevel: level };
runtime.emit({ type: "thinking_level_changed", level });
expect(changes).toEqual([
{ type: "thinking_option_changed", provider: "pi", thinkingOptionId: level },
]);
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: level });
},
);
test.each([undefined, null, "invalid", 123])(
"ignores a thinking change with invalid level %s",
async (level) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const initialThinking = session.describePersistence()?.metadata?.thinkingOptionId;
const changes: AgentStreamEvent[] = [];
session.subscribe((event) => {
if (event.type === "thinking_option_changed") changes.push(event);
});
pi.latestSession().emit({ type: "thinking_level_changed", level });
expect(changes).toEqual([]);
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(initialThinking);
},
);
test("surfaces state refresh failures after a Pi thinking update", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
@@ -1005,6 +1005,8 @@ function isProcessExitEvent(
function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent {
switch (event.type) {
case "thinking_level_changed":
return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null;
case "agent_start":
case "turn_start":
case "message_start":
@@ -2136,6 +2138,15 @@ export class PiRpcAgentSession implements AgentSession {
}
switch (event.type) {
case "thinking_level_changed":
this.state = { ...this.state, thinkingLevel: event.level };
this.config.thinkingOptionId = event.level;
this.emit({
type: "thinking_option_changed",
provider: this.provider,
thinkingOptionId: event.level,
});
return;
case "agent_start":
this.activeTurnStarted = true;
this.clearNoTurnBuffers();
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import type { PiAgentMessage } from "../../rpc-types.js";
describe("@tintinweb/pi-subagents adapter", () => {
test("exposes the background output file while the child is running", () => {
@@ -22,6 +23,27 @@ describe("@tintinweb/pi-subagents adapter", () => {
]);
expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]);
});
test("marks a foreground child that wrapped up at its turn limit as completed", () => {
const mapping = createPiExtensionHost().mapToolCall({
callId: "call-1",
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: {
details: { agentId: "native-1", status: "steered" },
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
},
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: "completed" }),
]);
expect(mapping?.detail).toEqual({
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect",
log: "Wrapped up (turn limit)",
});
});
test("maps captured foreground lifecycle live and on replay", async () => {
const events = await verifySubagentFixture(
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
@@ -65,6 +87,134 @@ describe("@tintinweb/pi-subagents adapter", () => {
call_277179: "completed",
});
});
test.each(["subagent-notification", "subagent-update"])(
"completes turn-limited children in a grouped %s",
(customType) => {
const host = createPiExtensionHost();
for (const id of ["1", "2", "3"]) {
host.mapToolCall({
callId: `call-${id}`,
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: { details: { agentId: `native-${id}`, status: "background" } },
});
}
const mapping = host.mapCustomMessage({
role: "custom",
customType,
content: "Wrapped up (turn limit)",
display: true,
timestamp: 0,
details: {
id: "native-1",
status: "steered",
others: [
{ id: "native-2", status: "steered" },
{ id: "native-3", status: "running" },
],
},
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: "completed" }),
expect.objectContaining({ id: "call-2", status: "completed" }),
expect.objectContaining({ id: "call-3", status: "running" }),
]);
},
);
test.each(["get_subagent_result", "steer_subagent"])(
"completes a turn-limited child when collected through %s",
(toolName) => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "call-1",
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: { details: { agentId: "native-1", status: "background" } },
});
const mapping = host.mapToolCall({
callId: "followup-1",
toolName,
args: { agent_id: "native-1" },
status: "completed",
result: {
details: { agentId: "native-1", status: "steered" },
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
},
});
expect(mapping?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]);
expect(mapping?.detail).toEqual({
type: "sub_agent",
description: undefined,
log: "Wrapped up (turn limit)",
});
},
);
test.each([
["background", "running"],
["queued", "running"],
["running", "running"],
["completed", "completed"],
["error", "failed"],
["aborted", "canceled"],
["stopped", "canceled"],
["unknown", "running"],
])("preserves the mapping of %s to %s", (status, expected) => {
const mapping = createPiExtensionHost().mapToolCall({
callId: "call-1",
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: { details: { agentId: "native-1", status } },
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: expected }),
]);
});
test("completes a turn-limited background child live and on history replay", async () => {
const path = new URL("./fixtures/background.json", import.meta.url);
const source = readSubagentFixture(path);
const notification = source.messages.find((message) => message.role === "custom");
const notificationDetails = notification?.details;
if (
!notificationDetails ||
typeof notificationDetails !== "object" ||
!("outputFile" in notificationDetails) ||
typeof notificationDetails.outputFile !== "string"
) {
throw new Error("Background fixture is missing its child output file");
}
const fixture = readSubagentFixture(path, {
from: notificationDetails.outputFile,
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
});
function wrapUp(message: PiAgentMessage): PiAgentMessage {
if (message.role !== "custom") return message;
const details = message.details;
if (!details || typeof details !== "object") {
throw new Error("Background fixture is missing its notification details");
}
return {
...message,
content: "Wrapped up (turn limit)",
details: { ...details, status: "steered" },
};
}
const events = await verifySubagentFixture({
...fixture,
events: fixture.events.map((event) =>
event.type === "message_end" ? { ...event, message: wrapUp(event.message) } : event,
),
messages: fixture.messages.map(wrapUp),
});
expect(
events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
).toEqual(["running", "running", "completed"]);
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
});
test("declines foreign Agent results", () => {
expect(
createPiExtensionHost().mapToolCall({
@@ -33,7 +33,8 @@ const Notification = NotificationEntry.extend({
others: z.array(NotificationEntry).optional(),
});
const status = (value: string): "running" | "completed" | "failed" | "canceled" => {
if (value === "completed") return "completed";
// A soft turn-limit wrap-up has finished; it is not an in-flight steering request.
if (value === "completed" || value === "steered") return "completed";
if (value === "error") return "failed";
if (value === "aborted" || value === "stopped") return "canceled";
return "running";
@@ -160,6 +160,7 @@ export type PiAssistantMessageEvent =
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
export type PiAgentSessionEvent =
| { type: "thinking_level_changed"; level: PiThinkingLevel }
| { type: "agent_start" }
| { type: "turn_start" }
| { type: "message_start"; message: PiAgentMessage }
@@ -23,6 +23,89 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
test.each(["..", "..."])(
"workspace suggestions resolve exact paths starting with %s",
async (prefix) => {
const cwd = tmpCwd();
const directoryName = `${prefix}docs`;
const nestedPath = `${directoryName}/notes.md`;
const fileName = `${prefix}notes.md`;
try {
mkdirSync(path.join(cwd, directoryName));
writeFileSync(path.join(cwd, nestedPath), "nested payload\n");
writeFileSync(path.join(cwd, fileName), "direct payload\n");
const file = await ctx.client.readFile(cwd, nestedPath);
expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n");
const nestedEntry = { path: nestedPath, kind: "file" };
const entries = [
nestedEntry,
{ path: directoryName, kind: "directory" },
{ path: fileName, kind: "file" },
];
const cases = entries.flatMap((entry) => [
{ query: `./${entry.path}`, entry },
{ query: path.resolve(cwd, entry.path), entry },
]);
cases.unshift({ query: nestedPath, entry: nestedEntry });
for (const { query, entry } of cases) {
const response = await ctx.client.getDirectorySuggestions({
cwd,
query,
limit: 1,
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
});
expect(response.error).toBeNull();
expect(response.entries).toEqual([entry]);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("workspace suggestions exclude exact parent and sibling paths", async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "workspace");
const sibling = path.join(directory, "workspace-other");
try {
mkdirSync(cwd);
mkdirSync(sibling);
writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n");
writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n");
for (const query of [
"./..",
"../workspace-other/outside.txt",
path.join(sibling, "outside.txt"),
]) {
const response = await ctx.client.getDirectorySuggestions({
cwd,
query,
limit: 1,
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
});
expect(response.error).toBeNull();
expect(response.entries).toEqual([]);
}
const control = await ctx.client.getDirectorySuggestions({
cwd,
query: "./visible.txt",
limit: 1,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(control.error).toBeNull();
expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test.each([
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
{ description: "an ordinary name", fileName: "notes.txt" },
+29
View File
@@ -42,6 +42,35 @@ describe("path equivalence", () => {
);
});
test.each([
{ root: "/repo", candidate: "/repo/..notes.txt", inside: true },
{ root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true },
{ root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true },
{ root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true },
{ root: "/repo", candidate: "/repo", inside: true },
{ root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false },
{ root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false },
{ root: "/repo", candidate: "/repo-other/notes.md", inside: false },
{ root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true },
{ root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true },
{ root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true },
{ root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false },
{ root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false },
{ root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false },
{
root: String.raw`\\server\share\repo`,
candidate: String.raw`\\server\share\repo\..drafts\notes.md`,
inside: true,
},
{
root: String.raw`\\?\C:\Repo`,
candidate: "C:\\Repo\\..notes.txt",
inside: true,
},
])("checks named path containment for $candidate", ({ root, candidate, inside }) => {
expect(isPathInsideRoot(root, candidate)).toBe(inside);
});
test("preserves the casing of Windows relative suffixes", () => {
expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD");
expect(
+5 -4
View File
@@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
if (
comparableRelative !== "" &&
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
) {
const isOutsideRoot =
comparableRelative === ".." ||
comparableRelative.startsWith(`..${platformPath.sep}`) ||
platformPath.isAbsolute(comparableRelative);
if (isOutsideRoot) {
return null;
}
+49 -2
View File
@@ -24,6 +24,16 @@ test("scrolls mobile plugin carousels without widening the page", async ({ page
await expectPageWithinViewport(page);
});
// Graphite's fixture description ends in raw README HTML with an unbreakable srcset URL.
test("keeps browse grids within a phone's width when a description has an unbreakable word", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/plugins/all");
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expectPageWithinViewport(page);
});
async function expectPageWithinViewport(page: Page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
@@ -88,6 +98,11 @@ test("browses from the directory into a category, a plugin, and its author", asy
"href",
"/plugins/category/git",
);
// The source is a folder in a monorepo; issues are filed on the repository.
await expect(page.getByRole("link", { name: "Report a bug" })).toHaveAttribute(
"href",
"https://github.com/omercnet/paseo-plugins/issues/new",
);
await page.getByRole("link", { name: "Omer Cohen" }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet$/);
@@ -96,7 +111,9 @@ test("browses from the directory into a category, a plugin, and its author", asy
await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0);
});
test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => {
test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", async ({
page,
}) => {
await page.goto("/plugins/all");
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute(
@@ -116,6 +133,21 @@ test("ranks plugins on browse pages by installs in a window or by newest", async
await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText(
"ago",
);
await page.getByRole("link", { name: "Recently updated" }).click();
await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^All plugins\s*2$/);
await expect(page.getByRole("main").getByRole("link", { name: /Defer|OMP/ })).toHaveText([
/Defer/,
/OMP/,
]);
});
test("shows each plugin's author with their avatar on browse cards", async ({ page }) => {
await page.goto("/plugins/all");
const card = page.getByRole("main").getByRole("link", { name: /Defer/ });
await expect(card).toContainText("Tom Gringauz");
await expect(card.locator('img[src="https://github.com/tomgrin10.png?size=80"]')).toHaveCount(1);
});
test("filters by search and clears to all plugins", async ({ page }) => {
@@ -577,7 +609,9 @@ test.describe("registry fixture layout", () => {
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);
// The only image is the author's avatar.
await expect(card.locator("img, video")).toHaveCount(1);
await expect(card.locator('img[src^="https://github.com/gpambrozio.png"]')).toHaveCount(1);
await card.click();
await expect(
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
@@ -611,6 +645,18 @@ test.describe("registry fixture layout", () => {
/Defer/,
]);
});
test("lists only plugins updated since they were listed under Recently updated", async ({
page,
}) => {
await openPlugins(page);
await expect(pluginCards(page, "Recently updated")).toHaveText([/Defer/, /OMP/]);
await page
.getByRole("region", { name: "Recently updated" })
.getByRole("link", { name: "See all" })
.click();
await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/);
});
});
function galleryTiles(links: Element[]) {
@@ -723,6 +769,7 @@ async function expectPluginOverviewSafe(page: Page) {
});
expect(violations).toEqual([]);
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Report a bug" })).toHaveCount(0);
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
await expect(
+37 -37
View File
@@ -84,9 +84,9 @@
"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",
"reviewedAt": "2026-09-24T00:00:00.000Z",
"publishedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-24T00: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."
},
@@ -117,9 +117,9 @@
"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",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"installs": 0,
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
},
@@ -150,9 +150,9 @@
"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",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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"
},
@@ -183,9 +183,9 @@
"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",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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"
},
@@ -215,9 +215,9 @@
"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",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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"
},
@@ -247,9 +247,9 @@
"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",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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"
},
@@ -277,9 +277,9 @@
},
"media": [],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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"
},
@@ -309,9 +309,9 @@
"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",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"installs": 0,
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
},
@@ -342,9 +342,9 @@
"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",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"installs": 0,
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
},
@@ -375,9 +375,9 @@
"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",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"installs": 0,
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
},
@@ -409,9 +409,9 @@
"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",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"installs": 0,
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
},
@@ -442,9 +442,9 @@
"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",
"reviewedAt": "2026-09-18T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-18T00:00:00.000Z",
"installs": 0,
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
},
@@ -475,9 +475,9 @@
"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",
"reviewedAt": "2026-10-03T09:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-10-03T09:00:00.000Z",
"installs": 0,
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
},
@@ -505,9 +505,9 @@
},
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"installs": 0,
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
},
@@ -538,9 +538,9 @@
"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",
"reviewedAt": "2026-09-18T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-18T00: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."
},
@@ -568,9 +568,9 @@
},
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"installs": 0,
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
},
@@ -599,16 +599,16 @@
"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",
"reviewedAt": "2026-10-03T15:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-10-03T15: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."
},
{
"id": "tomgrin10/graphite",
"name": "Graphite",
"description": "Workspace-native Graphite stack status and PR action radar for Paseo.",
"description": "Workspace-native Graphite stack status and PR action radar for Paseo. <p align=\"center\"> <picture> <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/xpufx/paseo/main/plugins/slash/docs/screenshots/slash-logo-for-dark-bg.png\"> <source media=\"(prefers-color-scheme: light)\" srcset",
"artifact": {
"kind": "npm",
"package": "paseo-graphite",
@@ -628,9 +628,9 @@
},
"media": [],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"installs": 0,
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
}
+48 -15
View File
@@ -16,7 +16,9 @@ import {
mostInstalled,
newestFirst,
type Plugin,
recentlyUpdated,
searchPlugins,
updatedAgo,
} from "./registry";
import { WindowSwitch } from "./window-switch";
@@ -34,7 +36,7 @@ const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text
const CLEAR_CLASS =
"text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground";
/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */
/** All plugins or one category: sidebar, Most installed / Newest / Recently updated tabs, and the card grid. */
export function BrowsePage({
plugins,
installs,
@@ -47,8 +49,7 @@ export function BrowsePage({
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const ordered =
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
const ordered = sortPlugins(plugins, installs, query);
// Search ranks by relevance; the tab's order breaks ties.
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
@@ -89,21 +90,19 @@ export function BrowsePage({
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
<p className="text-sm text-muted-foreground">
{query.sort === "updated"
? "No plugins have been updated since they were listed."
: "No plugins in this category yet."}
</p>
)}
{results.length > 0 && (
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
) : (
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
</PluginCard>
),
)}
{results.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<CardDetail plugin={plugin} installs={installs} now={now} query={query} />
</PluginCard>
))}
</div>
)}
</PluginSection>
@@ -113,6 +112,33 @@ export function BrowsePage({
);
}
function sortPlugins(
plugins: Plugin[],
installs: Record<string, InstallCounts>,
query: BrowseQuery,
): Plugin[] {
if (query.sort === "new") return newestFirst(plugins);
if (query.sort === "updated") return recentlyUpdated(plugins);
return mostInstalled(plugins, installs, query.window);
}
/** What the card shows beside the author: the date the tab sorts by, or the install count. */
function CardDetail({
plugin,
installs,
now,
query,
}: {
plugin: Plugin;
installs: Record<string, InstallCounts>;
now: string;
query: BrowseQuery;
}) {
if (query.sort === "new") return addedAgo(plugin, now);
if (query.sort === "updated") return updatedAgo(plugin, now);
return <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />;
}
interface NavEntry {
href: string;
label: string;
@@ -208,6 +234,13 @@ function SortTabs({ query }: { query: BrowseQuery }) {
>
Newest
</a>
<a
href={browseHref({ ...query, sort: "updated" })}
aria-current={query.sort === "updated" ? "page" : undefined}
className={query.sort === "updated" ? TAB_ON : TAB_OFF}
>
Recently updated
</a>
</div>
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
</div>
+3 -3
View File
@@ -5,7 +5,7 @@ export const DOCS_URL = "/docs/plugins";
export const SUBMIT_URL =
"https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml";
export type BrowseSort = "installs" | "new";
export type BrowseSort = "installs" | "new" | "updated";
export interface BrowseQuery {
/** Absent for all plugins. */
category?: CategorySlug;
@@ -24,7 +24,7 @@ export function parseSearchTerm(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function parseSort(value: unknown): BrowseSort | undefined {
return value === "new" ? value : undefined;
return value === "new" || value === "updated" ? value : undefined;
}
export function pluginHref(id: string): string {
@@ -37,7 +37,7 @@ export function browseHref({ category, sort, window, q }: BrowseQuery): string {
const path = category ? categoryHref(category) : "/plugins/all";
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort === "new") params.set("sort", sort);
if (sort !== "installs") params.set("sort", sort);
else if (window !== DEFAULT_WINDOW) params.set("window", window);
const search = params.toString();
return search ? `${path}?${search}` : path;
+15 -6
View File
@@ -1,7 +1,7 @@
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
import { authorAvatarUrl, getAuthor, type Plugin } from "./registry";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
@@ -25,6 +25,15 @@ function render(markdown: string) {
function attribute(element: Element, name: string) {
return element.attrs.find((attr) => attr.name === name)?.value;
}
function imageSources(html: string) {
return elements(html)
.filter((node) => node.tagName === "img")
.map((node) => attribute(node, "src"));
}
/** Cards show the author's GitHub avatar beside the plugin's own images. */
function avatarUrl(plugin: Plugin) {
return authorAvatarUrl(getAuthor(plugin));
}
const allowedTags = new Set([
"div",
"p",
@@ -118,7 +127,7 @@ describe("registry images", () => {
createElement(PluginCard, { plugin }),
]) {
expect(
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)),
).toEqual([]);
}
});
@@ -131,8 +140,8 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", ()
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual([source]);
expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([
source,
avatarUrl(plugin),
]);
});
+6 -22
View File
@@ -35,19 +35,20 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
}
/**
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
* then author and `children`, such as when the plugin was added or its install count.
* Gallery card, in three rows under the screenshot: tile and name, description, then author and
* `children`, such as when the plugin was added or its install count.
*/
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
// Grid items size to their content by default; min-w-0 keeps the card in its column.
<a href={pluginHref(plugin.id)} className="group block min-w-0">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
<p className="mt-2 line-clamp-2 min-h-10 break-words text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
@@ -64,23 +65,6 @@ export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?:
);
}
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
return (
<a href={pluginHref(plugin.id)} className="group block">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</a>
);
}
/** Ranked row for the directory's Most installed list. */
export function PluginRankRow({
plugin,
@@ -76,7 +76,7 @@ export function PluginSearch({
onSubmit={handleSubmit}
className={className}
>
{scope.sort === "new" && <input type="hidden" name="sort" value="new" />}
{scope.sort !== "installs" && <input type="hidden" name="sort" value={scope.sort} />}
{scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && (
<input type="hidden" name="window" value={scope.window} />
)}
+52 -11
View File
@@ -10,7 +10,10 @@ import {
newestFirst,
type Plugin,
readmeBody,
recentlyUpdated,
reportBugUrl,
searchPlugins,
updatedAgo,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
@@ -76,22 +79,22 @@ describe("plugin registry", () => {
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first-listing date, not submission or update dates", () => {
it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
submittedAt: "2026-09-20",
reviewedAt: "2026-09-21",
updatedAt: "2026-10-02",
publishedAt: "2026-09-02",
submittedAt: "2026-09-30T00:00:00.000Z",
publishedAt: "2026-09-30T06:00:00.000Z",
reviewedAt: "2026-10-02T00:00:00.000Z",
updatedAt: "2026-10-02T00:00:00.000Z",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
submittedAt: "2026-09-01",
reviewedAt: "2026-09-01",
updatedAt: "2026-09-01",
publishedAt: "2026-09-30",
submittedAt: "2026-09-02T00:00:00.000Z",
publishedAt: "2026-09-30T08:00:00.000Z",
reviewedAt: "2026-09-30T08:00:00.000Z",
updatedAt: "2026-09-30T08:00:00.000Z",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
@@ -99,9 +102,33 @@ describe("plugin registry", () => {
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(addedAgo(listedFirst, now)).toBe("4w ago");
expect(updatedAgo(listedFirst, now)).toBe("yesterday");
});
it("orders by listing date and by installs in a window, keeping index order for ties", () => {
it("lists only plugins updated after their first listing as recently updated, latest update first", () => {
const bumped = {
...plugin,
id: "acme/bumped",
publishedAt: "2026-09-10T00:00:00.000Z",
updatedAt: "2026-10-01T09:00:00.000Z",
};
const bumpedLater = {
...plugin,
id: "acme/bumped-later",
publishedAt: "2026-09-20T00:00:00.000Z",
updatedAt: "2026-10-01T17:00:00.000Z",
};
const neverUpdated = {
...plugin,
id: "acme/never-updated",
publishedAt: "2026-10-02T00:00:00.000Z",
updatedAt: "2026-10-02T00:00:00.000Z",
};
expect(recentlyUpdated([bumped, neverUpdated, bumpedLater]).map((p) => p.id)).toEqual([
"acme/bumped-later",
"acme/bumped",
]);
});
it("orders by first listing time and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" };
const twin = { ...plugin, id: "acme/twin" };
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
@@ -266,6 +293,20 @@ describe("plugin registry", () => {
expect(installCommand(plugin)).toBe("paseo plugin add acme/example");
expect(formatInstalls(1250)).toBe("1.3k");
});
it("reports bugs on the source repository's GitHub issue tracker", () => {
const at = (url: string) => reportBugUrl({ ...plugin, repository: { url } });
expect(at("https://github.com/acme/example")).toBe(
"https://github.com/acme/example/issues/new",
);
expect(at("https://github.com/acme/plugins.git")).toBe(
"https://github.com/acme/plugins/issues/new",
);
expect(at("https://github.com/acme/plugins/tree/HEAD/packages/example")).toBe(
"https://github.com/acme/plugins/issues/new",
);
expect(at("https://gitlab.com/acme/example")).toBeUndefined();
expect(at("javascript:alert(1)")).toBeUndefined();
});
});
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
+32 -3
View File
@@ -1,4 +1,9 @@
import type { z } from "zod";
import {
isGitHubHost,
parseGitHubRemoteIdentity,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
@@ -53,6 +58,14 @@ export function npmUrl(plugin: Plugin): string | undefined {
? `https://www.npmjs.com/package/${plugin.artifact.package}`
: undefined;
}
/** A new issue on the plugin's GitHub repository; absent when the source is hosted elsewhere. */
export function reportBugUrl(plugin: Plugin): string | undefined {
const location = parseGitRemoteLocation(plugin.repository.url);
if (!location || !isGitHubHost(location.host)) return undefined;
// A monorepo source points at the plugin's folder; issues belong to the repository.
const repository = parseGitHubRemoteIdentity(location.path.split("/").slice(0, 2).join("/"));
return repository ? `https://github.com/${repository.repo}/issues/new` : undefined;
}
export function authorNpmUrl(author: Author): string | undefined {
return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined;
}
@@ -106,9 +119,18 @@ export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[]
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []);
}
/** Most recently listed first; equal dates keep index order. */
/**
* Most recently listed first. A slow review does not push a plugin down, and an update does not
* bring it back up. Equal times keep index order.
*/
export function newestFirst(plugins: Plugin[]): Plugin[] {
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
return [...plugins].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
}
/** Plugins with a version approved after their first listing, most recent update first. */
export function recentlyUpdated(plugins: Plugin[]): Plugin[] {
return plugins
.filter((plugin) => Date.parse(plugin.updatedAt) > Date.parse(plugin.publishedAt))
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
@@ -121,7 +143,14 @@ export function mostInstalled(
}
/** How long ago the plugin was first listed, relative to the loader's clock. */
export function addedAgo(plugin: Plugin, now: string): string {
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
return ago(plugin.publishedAt, now);
}
/** How long ago the plugin's current version was approved, relative to the loader's clock. */
export function updatedAgo(plugin: Plugin, now: string): string {
return ago(plugin.updatedAt, now);
}
function ago(date: string, now: string): string {
const days = Math.floor((Date.parse(now) - Date.parse(date)) / DAY_MS);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
@@ -2,7 +2,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails";
import { NewPluginCard, PluginCard } from "./plugin-card";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
@@ -29,6 +29,8 @@ const plugin: Plugin = {
installs: 42,
};
const avatar = 'src="https://github.com/acme.png?size=80"';
describe("plugin card thumbnails", () => {
it("uses the first image in the plugin's media, skipping a leading video", () => {
const html = renderToStaticMarkup(
@@ -42,7 +44,7 @@ describe("plugin card thumbnails", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html).not.toContain("<img");
expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]);
expect(html).not.toContain("<video");
expect(html).toContain(">E</div>");
});
@@ -56,11 +58,14 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
it.each([
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
const html = renderToStaticMarkup(card());
it("shows the author's avatar and name beside the card detail", () => {
const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today"));
expect(html).toContain(avatar);
expect(html).toMatch(/>acme<\/span><span aria-hidden="true">·<\/span><span[^>]*>today<\/span>/);
});
it("renders a thumbnail instead of downloading the original screenshot", () => {
const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today"));
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
@@ -1,5 +1,6 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react";
import { GitHubIcon } from "~/components/brand-icons";
import { CodeBlock } from "~/components/code-block";
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
import { SiteShell } from "~/components/site-shell";
@@ -14,6 +15,7 @@ import {
npmUrl,
pluginVersion,
readmeBody,
reportBugUrl,
} from "~/plugins";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
@@ -48,6 +50,8 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const REPORT_BUG_CLASS =
"inline-flex items-center gap-2 rounded-lg border border-white/12 px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground";
const AUTHOR_LINK_CLASS =
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
@@ -56,6 +60,7 @@ function PluginPage() {
const category = getCategory(plugin.categories[0]);
const author = getAuthor(plugin);
const npm = npmUrl(plugin);
const reportBug = reportBugUrl(plugin);
return (
<SiteShell width="default">
@@ -64,8 +69,16 @@ function PluginPage() {
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
<div className="mt-6 flex flex-wrap items-center gap-3">
<div className="w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
{reportBug && (
<PluginContentLink href={reportBug} className={REPORT_BUG_CLASS}>
<GitHubIcon className="h-4 w-4" />
Report a bug
</PluginContentLink>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
+33 -8
View File
@@ -27,6 +27,8 @@ import {
mostInstalled,
newestFirst,
pluginOwner,
recentlyUpdated,
updatedAgo,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
@@ -41,7 +43,7 @@ import {
parseWindow,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -61,9 +63,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
// Card sections: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 *:w-[70%] *:flex-shrink-0 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 sm:*:w-auto lg:grid-cols-4";
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
@@ -79,7 +81,7 @@ export const Route = createFileRoute("/plugins/")({
const sort = parseSort(params.get("sort")) ?? "installs";
const window = parseWindow(params.get("window")) ?? DEFAULT_WINDOW;
const q = parseSearchTerm(params.get("q"));
if (category || sort === "new" || q)
if (category || sort !== "installs" || q)
throw redirect({
href: browseHref({ category: category?.slug, sort, window, q }),
statusCode: 301,
@@ -100,6 +102,7 @@ function PluginsPage() {
const window = Route.useSearch().window ?? DEFAULT_WINDOW;
const featured = featuredPlugins(plugins, featuredIds);
const newest = newestFirst(plugins).slice(0, NEW_COUNT);
const updated = recentlyUpdated(plugins).slice(0, NEW_COUNT);
const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT);
const windowHrefs = useMemo(
() => ({
@@ -138,9 +141,9 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</NewPluginCard>
</PluginCard>
))}
</div>
</PluginSection>
@@ -156,13 +159,35 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</NewPluginCard>
</PluginCard>
))}
</div>
</PluginSection>
{updated.length > 0 && (
<PluginSection labelledBy="recently-updated">
<PluginSectionHeader>
<PluginSectionTitle id="recently-updated">Recently updated</PluginSectionTitle>
<a
href={browseHref({ sort: "updated", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{updated.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
{updatedAgo(plugin, now)}
</PluginCard>
))}
</div>
</PluginSection>
)}
<PluginSection labelledBy="categories">
<PluginSectionTitle id="categories">Categories</PluginSectionTitle>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">