From 1c624aac287bed79a5ff20460719fe1068b82ba6 Mon Sep 17 00:00:00 2001 From: Tioit-Wang Date: Sat, 17 Oct 2026 01:38:29 +0800 Subject: [PATCH 1/4] feat(plugins): add ratings and reviews with installed card grid Installed plugins read as a card grid with one clear action per card and their trust, update, and scope state visible, and marketplace cards carry public rating summaries. The renderer reads reviews anonymously through host-core: two read-only RPCs proxy the plugin center's public summary and review endpoints, build the review webpage URL from the effective trusted center origin, and bound every request with a deadline and response cap while never holding host state across network I/O. The reads stay separate from install and update: a legacy or unreachable center surfaces as an unavailable state rather than fabricated zeros, ids are deduplicated and batched, and both response and result schema are validated. Writing a review happens on the plugin center's web page; the desktop has no login and no write path. fixes #1678 fixes #1679 --- apps/desktop/electron/main/ipc/market-ipc.ts | 15 + .../plugins/InstalledPluginsPanel.tsx | 278 +++--- .../src/features/plugins/MarketplacePanel.tsx | 7 +- .../features/plugins/PluginDetailSheet.tsx | 68 +- .../features/plugins/PluginReviewsDrawer.tsx | 348 ++++++++ .../src/features/plugins/presentation.tsx | 109 ++- apps/desktop/src/features/plugins/reviews.ts | 300 +++++++ .../src/features/plugins/usePluginReviews.ts | 121 +++ .../src/features/plugins/usePluginsPage.ts | 79 +- apps/desktop/src/lib/api.ts | 16 + apps/desktop/src/styles/plugins.css | 385 ++++++++- apps/desktop/src/styles/responsive.css | 6 +- apps/desktop/test/extensions-page.test.mjs | 5 +- apps/desktop/test/market-review-ipc.test.mjs | 168 ++++ .../test/plugin-reviews-logic.test.mjs | 324 +++++++ .../test/plugin-reviews-surface.test.mjs | 252 ++++++ apps/desktop/test/plugins-page-style.test.mjs | 47 +- crates/host-core/src/plugins.rs | 3 +- crates/host-core/src/plugins/reviews.rs | 760 +++++++++++++++++ crates/host-core/src/plugins/tests.rs | 5 +- crates/host-core/src/rpc/mod.rs | 11 + .../host-core/src/rpc/plugin_reviews_rpc.rs | 195 +++++ .../src/rpc/plugin_reviews_rpc_tests.rs | 791 ++++++++++++++++++ docs/privacy-policy.md | 11 +- docs/spec/04-ux/01-ui-ia.md | 16 +- docs/spec/06-delivery/04-e2e-test-plan.md | 38 +- .../06-delivery/plugin-reviews-test-plan.md | 350 ++++++++ docs/spec/07-plugins/07-plugin-marketplace.md | 71 +- docs/spec/07-plugins/14-plugin-roadmap.md | 4 +- docs/spec/07-plugins/15-plugin-center.md | 17 + packages/i18n/src/locales/de/index.ts | 35 + packages/i18n/src/locales/en/index.ts | 35 + packages/i18n/src/locales/es/index.ts | 35 + packages/i18n/src/locales/fr/index.ts | 35 + packages/i18n/src/locales/ko/index.ts | 35 + packages/i18n/src/locales/pt-BR/index.ts | 35 + packages/i18n/src/locales/tr/index.ts | 35 + packages/i18n/src/locales/zh-CN/index.ts | 35 + packages/i18n/src/locales/zh-TW/index.ts | 35 + packages/shared/src/changelog-de.ts | 2 + packages/shared/src/changelog-en.ts | 2 + packages/shared/src/changelog-es.ts | 2 + packages/shared/src/changelog-fr.ts | 2 + packages/shared/src/changelog-ko.ts | 2 + packages/shared/src/changelog-pt-BR.ts | 2 + packages/shared/src/changelog-tr.ts | 2 + packages/shared/src/changelog-zh-CN.ts | 2 + packages/shared/src/changelog-zh-TW.ts | 2 + packages/shared/src/protocol.ts | 2 + packages/shared/src/types/marketplace.ts | 58 ++ scripts/e2e-installed-grid.mjs | 116 +++ scripts/e2e-plugin-detail-focus.mjs | 2 + scripts/e2e-plugin-reviews-drawer.mjs | 113 +++ scripts/e2e/installed-grid.tsx | 572 +++++++++++++ scripts/e2e/plugin-detail-focus.tsx | 3 + scripts/e2e/plugin-reviews-drawer.tsx | 718 ++++++++++++++++ 56 files changed, 6512 insertions(+), 205 deletions(-) create mode 100644 apps/desktop/src/features/plugins/PluginReviewsDrawer.tsx create mode 100644 apps/desktop/src/features/plugins/reviews.ts create mode 100644 apps/desktop/src/features/plugins/usePluginReviews.ts create mode 100644 apps/desktop/test/market-review-ipc.test.mjs create mode 100644 apps/desktop/test/plugin-reviews-logic.test.mjs create mode 100644 apps/desktop/test/plugin-reviews-surface.test.mjs create mode 100644 crates/host-core/src/plugins/reviews.rs create mode 100644 crates/host-core/src/rpc/plugin_reviews_rpc.rs create mode 100644 crates/host-core/src/rpc/plugin_reviews_rpc_tests.rs create mode 100644 docs/spec/06-delivery/plugin-reviews-test-plan.md create mode 100644 scripts/e2e-installed-grid.mjs create mode 100644 scripts/e2e-plugin-reviews-drawer.mjs create mode 100644 scripts/e2e/installed-grid.tsx create mode 100644 scripts/e2e/plugin-reviews-drawer.tsx diff --git a/apps/desktop/electron/main/ipc/market-ipc.ts b/apps/desktop/electron/main/ipc/market-ipc.ts index cb8550c1ee..3508e23d19 100644 --- a/apps/desktop/electron/main/ipc/market-ipc.ts +++ b/apps/desktop/electron/main/ipc/market-ipc.ts @@ -1,4 +1,5 @@ import { IPC, ErrorCodes, trustedExtensionCommandId, trustedExtensionCommandName } from "@pi-desktop/shared"; +import type { PluginReviewQuery } from "@pi-desktop/shared"; import { builtinPaletteItems } from "../builtin-commands"; import type { AgentExtensionBridge } from "../agent-extensions"; import type { HostProcess } from "../host-process"; @@ -103,6 +104,20 @@ export function registerMarketIpc({ return host.call<{ cancelled: boolean; id: string }>("market.cancelInstall", payload); }); + // Public reviews are read anonymously through the host: the renderer names + // plugin ids or one query, and host-core owns the trusted center origin, + // the bounds, and the error mapping. Nothing here authenticates or mutates. + handle(IPC.invoke.marketReviewSummaries, async (payload?: { pluginIds?: unknown }) => { + if (!host) throw new Error("host unavailable"); + const pluginIds = Array.isArray(payload?.pluginIds) ? payload.pluginIds : []; + return host.call("market.reviewSummaries", { pluginIds }); + }); + + handle(IPC.invoke.marketReviews, async (payload?: Partial) => { + if (!host) throw new Error("host unavailable"); + return host.call("market.reviews", { ...(payload ?? {}) }); + }); + handle(IPC.invoke.commandPaletteSearch, async (query: string) => { const q = (query || "").toLowerCase(); const builtin = builtinPaletteItems(); diff --git a/apps/desktop/src/features/plugins/InstalledPluginsPanel.tsx b/apps/desktop/src/features/plugins/InstalledPluginsPanel.tsx index e6abe19b28..2fba42045c 100644 --- a/apps/desktop/src/features/plugins/InstalledPluginsPanel.tsx +++ b/apps/desktop/src/features/plugins/InstalledPluginsPanel.tsx @@ -8,18 +8,20 @@ import { IconReview, IconSearch, IconSettings, + IconShield, IconTrash, } from "../../components/icons"; import { ScopeControl } from "../../components/extensions/ScopeControl"; import { AnchoredMenu } from "../../components/settings/AnchoredMenu"; import type { ActivationScope } from "@pi-desktop/shared"; -import { GROUP_LABEL_KEYS, TEMPLATE_IDS } from "./model"; -import { PluginRowDetails } from "./presentation"; +import { GROUP_LABEL_KEYS, TEMPLATE_IDS, showsVerifiedBadge } from "./model"; +import { PluginRowDetails, RatingChip } from "./presentation"; import type { PluginsPageModel } from "./usePluginsPage"; import { api } from "../../lib/api"; export function InstalledPluginsPanel({ t, + locale, plugins, setTab, loadDev, @@ -38,6 +40,7 @@ export function InstalledPluginsPanel({ reloadPlugin, setSettingsPlugin, servicesByPlugin, + reviewSummaries, }: PluginsPageModel) { return (
- - {broken ? ( - - ) : ( - - )} - -
+
+ + {broken ? ( + + ) : ( + + )} +
{plugin.name} + {showsVerifiedBadge(plugin.marketplace) ? ( + + + + ) : null} {plugin.source === "dev" ? ( {t("plugins.tagLocal")} ) : null} @@ -123,12 +141,18 @@ export function InstalledPluginsPanel({ {t("plugins.tagBundled")} ) : null}
+
+
{plugin.id} · v{plugin.version} +
{plugin.errorMessage ? (

{plugin.errorMessage}

@@ -139,26 +163,6 @@ export function InstalledPluginsPanel({ />
- {update ? ( - - ) : null} -
- {plugin.ui?.panel ? ( + {plugin.enabled && update ? ( + + ) : canOpenPanel ? ( + + ) : null} + {plugin.enabled && plugin.settings?.length ? ( + setSettingsPlugin(plugin)} + > + + + ) : null} + setRowMenu(null)} + menuClassName="plugins-menu is-end" + label={t("plugins.rowActions", { name: plugin.name })} + role="menu" + align="end" + trigger={(ref) => ( - void run(() => api.openPluginPanel(plugin.id)) + setRowMenu((cur) => + cur === plugin.id ? null : plugin.id, + ) } > - + - ) : null} - {plugin.enabled && plugin.settings?.length ? ( - setSettingsPlugin(plugin)} - > - - - ) : null} - setRowMenu(null)} - menuClassName="plugins-menu is-end" - label={t("plugins.rowActions", { name: plugin.name })} - role="menu" - align="end" - trigger={(ref) => ( - + {plugin.source === "dev" ? ( + + ) : null} + + {/* A bundled plugin belongs to the + application: the host refuses the + removal, so the action is not offered + (ADR 0104, ADR 0241). */} + {plugin.bundled ? null : ( + <> +
- ) : null} - - {/* A bundled plugin belongs to the - application: the host refuses the - removal, so the action is not offered - (ADR 0104, ADR 0241). */} - {plugin.bundled ? null : ( - <> -
- - - )} - -
+ + )} +
-
+ ); })}
diff --git a/apps/desktop/src/features/plugins/MarketplacePanel.tsx b/apps/desktop/src/features/plugins/MarketplacePanel.tsx index 1029f171f9..8134024fde 100644 --- a/apps/desktop/src/features/plugins/MarketplacePanel.tsx +++ b/apps/desktop/src/features/plugins/MarketplacePanel.tsx @@ -1,6 +1,6 @@ import { Button, cx } from "../../components/ui"; import { IconCheck, IconSearch, IconShield } from "../../components/icons"; -import { PermissionChips } from "./presentation"; +import { PermissionChips, RatingChip } from "./presentation"; import { formatDate, monogram, @@ -25,6 +25,7 @@ export function MarketplacePanel({ openDetail, busyId, queueInstall, + reviewSummaries, }: PluginsPageModel) { return (
) : null} + diff --git a/apps/desktop/src/features/plugins/PluginDetailSheet.tsx b/apps/desktop/src/features/plugins/PluginDetailSheet.tsx index c21a309a20..ad0fd30c3b 100644 --- a/apps/desktop/src/features/plugins/PluginDetailSheet.tsx +++ b/apps/desktop/src/features/plugins/PluginDetailSheet.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Button, TooltipButton, cx, portalOverlay } from "../../components/ui"; import { useBlockingOverlay } from "../../lib/blocking-overlay"; import { @@ -22,6 +22,8 @@ import { versionInstallable, versionWithdrawn, } from "./model"; +import { ReviewSummaryBlock } from "./presentation"; +import { PluginReviewsDrawer } from "./PluginReviewsDrawer"; import type { PluginsPageModel } from "./usePluginsPage"; function PluginDetailBlockingHost({ children }: { children: ReactNode }) { @@ -46,7 +48,24 @@ export function PluginDetailSheet({ busyId, queueInstall, setSelectedVersion, + reviewSummaries, + reviewsStatus, + reviewsId, + openReviews, + closeReviews, }: PluginsPageModel) { + const reviewSummary = detail ? reviewSummaries[detail.id] : undefined; + const reviewsOpen = selectedId !== null && reviewsId === selectedId; + /** The button that opened the drawer; focus returns here when it closes. */ + const reviewsOpenerRef = useRef(null); + const wasReviewsOpen = useRef(false); + useEffect(() => { + if (wasReviewsOpen.current && !reviewsOpen) { + reviewsOpenerRef.current?.focus(); + } + wasReviewsOpen.current = reviewsOpen; + }, [reviewsOpen]); + return selectedId ? portalOverlay( @@ -56,12 +75,17 @@ export function PluginDetailSheet({ className="plugins-sheet-scrim" aria-label={t("plugins.closeDetail")} onClick={closeDetail} + inert={reviewsOpen} /> + {reviewsOpen && detail ? ( + + ) : null}
, ) diff --git a/apps/desktop/src/features/plugins/PluginReviewsDrawer.tsx b/apps/desktop/src/features/plugins/PluginReviewsDrawer.tsx new file mode 100644 index 0000000000..c789dda10d --- /dev/null +++ b/apps/desktop/src/features/plugins/PluginReviewsDrawer.tsx @@ -0,0 +1,348 @@ +/** + * The public review drawer: a read-only list of what the plugin centre shows, + * opened from the detail sheet's ratings summary. + * + * It is an independent surface at the same dialog level as the detail sheet — + * it slides over it while the sheet stays mounted underneath, so closing the + * drawer returns to the exact reading position it covered. Reviews are written + * on the plugin centre; the desktop never signs in, never posts, and never + * offers signed-in actions, so the composer is a permanently greyed field and + * the one call to action is a real link out to the centre's page. + */ +import { useEffect, useId, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Textarea, TooltipButton, cx } from "../../components/ui"; +import { IconExternal, IconStar, IconX } from "../../components/icons"; +import { api } from "../../lib/api"; +import { useAppStore } from "../../stores/app-store"; +import { formatDate } from "./model"; +import { + REVIEW_SENTIMENTS, + REVIEW_SORT_LABEL_KEYS, + REVIEW_SORTS, + REVIEW_SENTIMENT_LABEL_KEYS, + formatReviewRating, + type ReviewSentiment, + type ReviewSort, +} from "./reviews"; +import { ReviewSummaryBlock } from "./presentation"; +import { usePluginReviews } from "./usePluginReviews"; + +/** What Tab can reach inside the drawer's own surface. */ +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "textarea:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +function focusableWithin(root: HTMLElement): HTMLElement[] { + return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter( + (element) => element.getAttribute("aria-hidden") !== "true", + ); +} + +export function PluginReviewsDrawer({ + pluginId, + pluginName, + onClose, +}: { + pluginId: string; + pluginName: string; + onClose: () => void; +}) { + const { t, i18n } = useTranslation(); + const locale = i18n.language; + const showToast = useAppStore((state) => state.showToast); + const { + state, + sentiment, + sort, + setSentiment, + setSort, + loadMore, + retry, + reviewUrl, + } = usePluginReviews(pluginId); + const dialogRef = useRef(null); + const titleId = useId(); + + // The drawer is the top layer when it mounts: focus it so Escape and Tab + // start here, and let the sheet restore its own opener on close. + useEffect(() => { + dialogRef.current?.focus(); + }, []); + + /** + * Escape and Tab belong to the top layer. Escape is claimed before anything + * under the drawer sees it (the detail sheet also stands down while this + * surface is open, see usePluginsPage); Tab cycles inside the dialog, so + * keyboard focus can never reach the covered sheet or the page behind it. + */ + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + return; + } + if (event.key !== "Tab") return; + const root = dialogRef.current; + if (!root) return; + const focusables = focusableWithin(root); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + const inside = active instanceof HTMLElement && root.contains(active); + if (!inside) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + return; + } + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [onClose]); + + /** Open the centre's page in the system browser; report a failure. */ + const openReviewLink = () => { + void (async () => { + try { + await api.browserOpenExternal(reviewUrl); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), { + variant: "error", + }); + } + })(); + }; + + const reviewSummary = state.summary; + const showSummary = !!reviewSummary && reviewSummary.ratingCount > 0; + const showList = state.reviews.length > 0; + const failedWithoutRows = state.status === "error" && !showList; + + return ( +
+ + ))} +
+
+ {REVIEW_SORTS.map((value: ReviewSort) => ( + + ))} +
+
+ + {state.status === "loading" && !showList ? ( +

+ {t("plugins.reviews.loading")} +

+ ) : failedWithoutRows ? ( +
+

+ {t("plugins.reviews.loadFailed")} +

+ {state.error ? ( +

{state.error}

+ ) : null} + +
+ ) : !showList ? ( +

{t("plugins.reviews.empty")}

+ ) : ( +
    + {state.reviews.map((review) => ( +
  • +
    + {review.authorName} + + +
    +
    + {formatDate(review.createdAt, locale)} + {review.pluginVersion ? ( + <> + + · + + {/* The author asserts this; it is not an installed + version, so it is labelled as what it is. */} + + {t("plugins.reviews.reviewVersion", { + version: review.pluginVersion, + })} + + + ) : null} + {review.editedAt ? ( + <> + + · + + {t("plugins.reviews.edited")} + + ) : null} + {review.helpfulCount > 0 ? ( + <> + + · + + + {t("plugins.reviews.helpfulCount", { + count: review.helpfulCount, + })} + + + ) : null} +
    +

    {review.body}

    +
  • + ))} +
+ )} + + {state.error && showList ? ( +
+ {state.error} + +
+ ) : null} + + {state.nextCursor ? ( +
+ +
+ ) : null} +
+ +
+