Compare commits

..
37 changed files with 191 additions and 1016 deletions
-16
View File
@@ -311,22 +311,6 @@ 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
@@ -1,62 +0,0 @@
// 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");
@@ -1,21 +0,0 @@
# 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.
@@ -1,65 +0,0 @@
// 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"));
}
+7 -3
View File
@@ -1,6 +1,10 @@
// 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";
// 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();
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
+2
View File
@@ -103,6 +103,7 @@ import {
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -138,6 +139,7 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
+1 -5
View File
@@ -21,7 +21,6 @@ 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[];
@@ -108,10 +107,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
if (!hasAppliedAppearance) return null;
return (
<ContributedThemesContext.Provider value={value}>
<AppearanceSystemBars />
{children}
</ContributedThemesContext.Provider>
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
);
}
@@ -1,11 +0,0 @@
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,
}));
@@ -1,3 +0,0 @@
export function AppearanceSystemBars() {
return null;
}
@@ -1,12 +0,0 @@
// 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();
@@ -1,56 +0,0 @@
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();
});
@@ -1,48 +0,0 @@
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 });
}
+1 -2
View File
@@ -25,8 +25,7 @@
"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:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
},
"dependencies": {
"@getpaseo/cli": "*",
+2 -3
View File
@@ -43,7 +43,6 @@ 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 {
@@ -774,8 +773,8 @@ async function createWindow(
registerBrowserWebviewNavigationGuards(contents);
});
showWindowWhenReady(mainWindow, {
showOnLoad: process.platform === "linux",
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
if (!app.isPackaged) {
@@ -1,57 +0,0 @@
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);
});
});
@@ -1,25 +0,0 @@
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);
}
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -243,11 +244,14 @@ interface Harness {
cleanup: () => void;
}
function createHarness(options?: { provider?: AgentProvider }): Harness {
function createHarness(options?: { provider?: AgentProvider; storage?: boolean }): Harness {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stream-coalescing-"));
const client = new TestAgentClient(options?.provider ?? "codex");
const manager = new AgentManager({
clients: { [client.provider]: client },
registry: options?.storage
? new AgentStorage(join(workdir, "agents"), createTestLogger())
: undefined,
idFactory: createIdFactory(),
logger: createTestLogger(),
});
@@ -1419,3 +1423,54 @@ describe("target coalesced behavior", () => {
}
});
});
describe("issue 3542 deferred flush diagnosis", () => {
test.each(["close", "archive"] as const)(
"%s drains a trailing flush before tearing down agent state",
async (operation) => {
vi.useFakeTimers();
const harness = createHarness({ storage: true });
try {
const { agentId, session } = await createManagedSession(harness);
session.pushEvent(assistant("first"));
await waitForSessionEventQueue();
session.pushEvent(assistant("-pending"));
await waitForSessionEventQueue();
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(1);
if (operation === "close") await harness.manager.closeAgent(agentId);
else await harness.manager.archiveAgent(agentId);
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2);
await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS * 2);
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2);
expect(harness.manager.getAgent(agentId)).toBeNull();
} finally {
await harness.manager.flush();
harness.cleanup();
}
},
);
test("deleting retained state with a live trailing flush does not throw", async () => {
vi.useFakeTimers();
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
session.pushEvent(assistant("first"));
await waitForSessionEventQueue();
session.pushEvent(assistant("-pending"));
await waitForSessionEventQueue();
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(1);
// Diagnostic adaptation: the report does not identify the operation that
// deleted its timeline. Direct deletion tests that condition, rather than
// claiming close or archive reaches it.
await harness.manager.deleteAgentState(agentId);
await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS * 2);
} finally {
await harness.manager.closeAgent(AGENT_IDS[0]);
harness.cleanup();
}
});
});
@@ -1943,48 +1943,6 @@ 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,8 +1005,6 @@ 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":
@@ -2138,15 +2136,6 @@ 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,7 +2,6 @@ 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", () => {
@@ -23,27 +22,6 @@ 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)),
@@ -87,134 +65,6 @@ 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,8 +33,7 @@ const Notification = NotificationEntry.extend({
others: z.array(NotificationEntry).optional(),
});
const status = (value: string): "running" | "completed" | "failed" | "canceled" => {
// 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 === "completed") return "completed";
if (value === "error") return "failed";
if (value === "aborted" || value === "stopped") return "canceled";
return "running";
@@ -160,7 +160,6 @@ 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,89 +23,6 @@ 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,35 +42,6 @@ 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(
+4 -5
View File
@@ -114,11 +114,10 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
const isOutsideRoot =
comparableRelative === ".." ||
comparableRelative.startsWith(`..${platformPath.sep}`) ||
platformPath.isAbsolute(comparableRelative);
if (isOutsideRoot) {
if (
comparableRelative !== "" &&
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
) {
return null;
}
+2 -49
View File
@@ -24,16 +24,6 @@ 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),
@@ -98,11 +88,6 @@ 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$/);
@@ -111,9 +96,7 @@ 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, by newest, or by latest update", async ({
page,
}) => {
test("ranks plugins on browse pages by installs in a window or by newest", 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(
@@ -133,21 +116,6 @@ test("ranks plugins on browse pages by installs in a window, by newest, or by la
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 }) => {
@@ -609,9 +577,7 @@ 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/ });
// 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 expect(card.locator("img, video")).toHaveCount(0);
await card.click();
await expect(
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
@@ -645,18 +611,6 @@ 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[]) {
@@ -769,7 +723,6 @@ 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-09-24T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "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"
},
@@ -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-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "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"
},
@@ -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-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "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"
},
@@ -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-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "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"
},
@@ -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-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "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"
},
@@ -277,9 +277,9 @@
},
"media": [],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "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"
},
@@ -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-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "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"
},
@@ -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-03T09:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T09: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."
},
@@ -505,9 +505,9 @@
},
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "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."
},
@@ -568,9 +568,9 @@
},
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "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."
},
@@ -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-03T15:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T15: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."
},
{
"id": "tomgrin10/graphite",
"name": "Graphite",
"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",
"description": "Workspace-native Graphite stack status and PR action radar for Paseo.",
"artifact": {
"kind": "npm",
"package": "paseo-graphite",
@@ -628,9 +628,9 @@
},
"media": [],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "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."
}
+15 -48
View File
@@ -16,9 +16,7 @@ import {
mostInstalled,
newestFirst,
type Plugin,
recentlyUpdated,
searchPlugins,
updatedAgo,
} from "./registry";
import { WindowSwitch } from "./window-switch";
@@ -36,7 +34,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 / Recently updated tabs, and the card grid. */
/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */
export function BrowsePage({
plugins,
installs,
@@ -49,7 +47,8 @@ export function BrowsePage({
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const ordered = sortPlugins(plugins, installs, query);
const ordered =
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
// 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;
@@ -90,19 +89,21 @@ export function BrowsePage({
</div>
)}
{results.length === 0 && !query.q && (
<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>
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<CardDetail plugin={plugin} installs={installs} now={now} query={query} />
</PluginCard>
))}
{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>
),
)}
</div>
)}
</PluginSection>
@@ -112,33 +113,6 @@ 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;
@@ -234,13 +208,6 @@ 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" | "updated";
export type BrowseSort = "installs" | "new";
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 === "updated" ? value : undefined;
return value === "new" ? 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 !== "installs") params.set("sort", sort);
if (sort === "new") params.set("sort", sort);
else if (window !== DEFAULT_WINDOW) params.set("window", window);
const search = params.toString();
return search ? `${path}?${search}` : path;
+6 -15
View File
@@ -1,7 +1,7 @@
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import { authorAvatarUrl, getAuthor, type Plugin } from "./registry";
import type { Plugin } from "./registry";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
@@ -25,15 +25,6 @@ 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",
@@ -127,7 +118,7 @@ describe("registry images", () => {
createElement(PluginCard, { plugin }),
]) {
expect(
imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)),
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
).toEqual([]);
}
});
@@ -140,8 +131,8 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", ()
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([
source,
avatarUrl(plugin),
]);
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual([source]);
});
+22 -6
View File
@@ -35,20 +35,19 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
}
/**
* 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.
* 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.
*/
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
// 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">
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
<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 break-words text-xs leading-5 text-muted-foreground">
<p className="mt-2 line-clamp-2 min-h-10 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">
@@ -65,6 +64,23 @@ export function PluginCard({ plugin, children }: { plugin: Plugin; children?: Re
);
}
/** 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 !== "installs" && <input type="hidden" name="sort" value={scope.sort} />}
{scope.sort === "new" && <input type="hidden" name="sort" value="new" />}
{scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && (
<input type="hidden" name="window" value={scope.window} />
)}
+11 -52
View File
@@ -10,10 +10,7 @@ import {
newestFirst,
type Plugin,
readmeBody,
recentlyUpdated,
reportBugUrl,
searchPlugins,
updatedAgo,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
@@ -79,22 +76,22 @@ describe("plugin registry", () => {
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => {
it("lists what's new by first-listing date, not submission or update dates", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
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",
submittedAt: "2026-09-20",
reviewedAt: "2026-09-21",
updatedAt: "2026-10-02",
publishedAt: "2026-09-02",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
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",
submittedAt: "2026-09-01",
reviewedAt: "2026-09-01",
updatedAt: "2026-09-01",
publishedAt: "2026-09-30",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
@@ -102,33 +99,9 @@ describe("plugin registry", () => {
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(updatedAgo(listedFirst, now)).toBe("yesterday");
expect(addedAgo(listedFirst, now)).toBe("4w ago");
});
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", () => {
it("orders by listing date 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([
@@ -293,20 +266,6 @@ 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. */
+3 -32
View File
@@ -1,9 +1,4 @@
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";
@@ -58,14 +53,6 @@ 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;
}
@@ -119,18 +106,9 @@ 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. A slow review does not push a plugin down, and an update does not
* bring it back up. Equal times keep index order.
*/
/** Most recently listed first; equal dates keep index order. */
export function newestFirst(plugins: Plugin[]): Plugin[] {
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));
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
@@ -143,14 +121,7 @@ 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 {
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);
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / 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 { PluginCard } from "./plugin-card";
import { NewPluginCard, PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
@@ -29,8 +29,6 @@ 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(
@@ -44,7 +42,7 @@ describe("plugin card thumbnails", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]);
expect(html).not.toContain("<img");
expect(html).not.toContain("<video");
expect(html).toContain(">E</div>");
});
@@ -58,14 +56,11 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
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"));
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());
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
@@ -1,6 +1,5 @@
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";
@@ -15,7 +14,6 @@ import {
npmUrl,
pluginVersion,
readmeBody,
reportBugUrl,
} from "~/plugins";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
@@ -50,8 +48,6 @@ 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";
@@ -60,7 +56,6 @@ function PluginPage() {
const category = getCategory(plugin.categories[0]);
const author = getAuthor(plugin);
const npm = npmUrl(plugin);
const reportBug = reportBugUrl(plugin);
return (
<SiteShell width="default">
@@ -69,16 +64,8 @@ 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 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 className="mt-6 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</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}>
+8 -33
View File
@@ -27,8 +27,6 @@ import {
mostInstalled,
newestFirst,
pluginOwner,
recentlyUpdated,
updatedAgo,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
@@ -43,7 +41,7 @@ import {
parseWindow,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { PluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -63,9 +61,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
// Card sections: a swipeable row on phones, a grid from `sm` up.
// What's new and Featured cards: 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 *:w-[70%] *:flex-shrink-0 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 sm:*:w-auto lg:grid-cols-4";
"-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";
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
@@ -81,7 +79,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 !== "installs" || q)
if (category || sort === "new" || q)
throw redirect({
href: browseHref({ category: category?.slug, sort, window, q }),
statusCode: 301,
@@ -102,7 +100,6 @@ 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(
() => ({
@@ -141,9 +138,9 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<NewPluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</PluginCard>
</NewPluginCard>
))}
</div>
</PluginSection>
@@ -159,35 +156,13 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<NewPluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
</NewPluginCard>
))}
</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">