feat(plugins): expand host.ui and add host.toast / host.utils (#2410)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-08 07:58:17 +02:00
committed by GitHub
parent fffde518dd
commit 6393d2df85
13 changed files with 645 additions and 12 deletions
@@ -12,7 +12,9 @@
* The plugin page also renders a `host.theme` readout kept current purely
* through `host.onThemeChange`, so an e2e can flip the app theme for real and
* prove the subscription fires in a browser (jsdom's MutationObserver is a
* shim, and jsdom cannot open a Radix tooltip from synthetic hover at all).
* shim, and jsdom cannot open a Radix tooltip from synthetic hover at all),
* plus a button that fires `host.toast.error` against the real sonner
* instance the app mounts.
*
* It also registers the plugin-hooks surface this fixture exists to drive
* end to end: a "Notes" task panel (registerTaskPanel, mobile-enabled) that
@@ -97,6 +99,20 @@
},
themeState.theme,
),
// host.toast is a per-plugin Proxy over sonner; unit tests only ever
// see it against a mocked sonner, so this button is how an e2e
// proves a real toast renders and that .error does NOT file a
// frontend-error report.
jsx(
"button",
{
"data-testid": "hello-toast-error",
onClick: function () {
host.toast.error("Plugin toast error");
},
},
"toast error",
),
);
}
@@ -494,6 +494,57 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
).toBeVisible();
});
// host.toast is a per-plugin Proxy over sonner. The unit tests only ever see
// it against a mocked sonner, so nothing has observed its runtime behavior:
// that a real toast renders, and that `.error` does NOT file a
// frontend-error report the way the app's own reporting seam does.
test("host.toast.error renders a real toast and files no frontend-error report", async ({
testPage,
}) => {
test.setTimeout(60_000);
await openInstallDialog(testPage);
await uploadPackage(testPage, PACKAGE_PATH);
await expect(testPage.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
await testPage.goto("/");
await testPage.reload();
await testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`).click();
const reportRequests: string[] = [];
testPage.on("request", (request) => {
if (request.url().includes("/api/v1/system/logs/frontend-errors")) {
reportRequests.push(request.url());
}
});
const consoleErrors: string[] = [];
testPage.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
const trigger = testPage.getByTestId("hello-toast-error");
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
// The real sonner <Toaster/> the app mounts in app/layout.tsx.
await expect(
testPage.locator("[data-sonner-toast]").filter({ hasText: "Plugin toast error" }),
).toBeVisible();
// The load-bearing assertion: the app's reporting seam would POST here on
// every .error, so a polling plugin would file an Error-level backend log
// entry per cycle. The report is scheduled in a microtask and sent async,
// so give it a real chance to fire before asserting it never did.
await testPage.waitForTimeout(500);
expect(reportRequests).toEqual([]);
// Attribution goes to the console instead, matching every other plugin
// failure path.
expect(consoleErrors.some((line) => line.includes(`toast.error from "${PLUGIN_ID}"`))).toBe(
true,
);
});
// host.theme is a live getter and host.onThemeChange is backed by a
// MutationObserver on <html>'s class. jsdom's MutationObserver is a shim, so
// only a browser proves a real theme flip reaches a plugin subscriber.
+121 -2
View File
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { formatDate, formatNumber, formatRelative } from "./formats";
import { formatDate, formatNumber, formatRelative, formatRelativeTime } from "./formats";
import { activateLocale } from "./index";
beforeAll(async () => {
@@ -84,3 +84,122 @@ describe("locale-aware Intl wrappers", () => {
await activateLocale("en");
});
});
/**
* `host.utils.formatRelativeTime` (docs/plans/plugins/PLUGIN-API.md). Unlike
* `formatRelative` above, this one goes through `Intl.RelativeTimeFormat`
* rather than the message catalog, so it covers the full range up to years
* and localizes without any keys — which is the point: three published
* plugins ship hand-rolled English-only versions of this.
*/
const THREE_HOURS_AGO = "3 hours ago";
describe("formatRelativeTime (en)", () => {
const now = new Date("2026-07-27T12:00:00Z").getTime();
const secondsAgo = (n: number) => new Date(now - n * 1000);
const secondsAhead = (n: number) => new Date(now + n * 1000);
it("returns an empty string for unparseable input", () => {
expect(formatRelativeTime("not-a-date", now)).toBe("");
expect(formatRelativeTime("", now)).toBe("");
});
it("collapses anything within ten seconds to 'now'", () => {
expect(formatRelativeTime(secondsAgo(0), now)).toBe("now");
expect(formatRelativeTime(secondsAgo(9), now)).toBe("now");
expect(formatRelativeTime(secondsAhead(9), now)).toBe("now");
});
// The threshold is tested against raw milliseconds, not the rounded second
// count. Rounding first moved the real cutoff to 9.5s and made it
// asymmetric — Math.round(-9.5) is -9 but Math.round(9.5) is 10 — so 9.5s
// ago read "now" while 9.5s ahead read "in 10 seconds". Reported by
// @yattdev on #2410.
it("uses a symmetric 10s 'now' cutoff that sub-second input cannot shift", () => {
const ms = (n: number) => new Date(now - n);
expect(formatRelativeTime(ms(9_400), now)).toBe("now");
expect(formatRelativeTime(ms(9_600), now)).toBe("now");
expect(formatRelativeTime(ms(9_900), now)).toBe("now");
expect(formatRelativeTime(ms(9_999), now)).toBe("now");
expect(formatRelativeTime(ms(10_000), now)).toBe("10 seconds ago");
expect(formatRelativeTime(ms(10_100), now)).toBe("10 seconds ago");
});
it("applies the same cutoff to future timestamps as to past ones", () => {
const ahead = (n: number) => new Date(now + n);
expect(formatRelativeTime(ahead(9_500), now)).toBe("now");
expect(formatRelativeTime(ahead(9_900), now)).toBe("now");
expect(formatRelativeTime(ahead(10_000), now)).toBe("in 10 seconds");
});
// The other half of the report — 59.6s rendering as "1 minute ago" — is
// intended. Unit selection rounds to the nearest unit, which describes
// 59.6s better than "59 seconds ago" would.
it("selects the nearest unit rather than truncating toward zero", () => {
expect(formatRelativeTime(new Date(now - 59_600), now)).toBe("1 minute ago");
expect(formatRelativeTime(new Date(now - 59_400), now)).toBe("59 seconds ago");
});
it("reports whole seconds below a minute", () => {
expect(formatRelativeTime(secondsAgo(10), now)).toBe("10 seconds ago");
expect(formatRelativeTime(secondsAgo(59), now)).toBe("59 seconds ago");
});
it("switches to minutes, hours, days and weeks at each boundary", () => {
expect(formatRelativeTime(secondsAgo(60), now)).toBe("1 minute ago");
expect(formatRelativeTime(secondsAgo(90), now)).toBe("1 minute ago");
expect(formatRelativeTime(secondsAgo(60 * 60), now)).toBe("1 hour ago");
expect(formatRelativeTime(secondsAgo(3 * 60 * 60), now)).toBe(THREE_HOURS_AGO);
expect(formatRelativeTime(secondsAgo(2 * 24 * 60 * 60), now)).toBe("2 days ago");
expect(formatRelativeTime(secondsAgo(3 * 7 * 24 * 60 * 60), now)).toBe("3 weeks ago");
expect(formatRelativeTime(secondsAgo(800 * 24 * 60 * 60), now)).toBe("2 years ago");
});
// numeric: "auto" is deliberate — CLDR phrases the small magnitudes
// idiomatically, which no interpolated number can do across locales.
it("uses idiomatic phrasing for a single unit either side", () => {
expect(formatRelativeTime(secondsAgo(24 * 60 * 60), now)).toBe("yesterday");
expect(formatRelativeTime(secondsAhead(24 * 60 * 60), now)).toBe("tomorrow");
expect(formatRelativeTime(secondsAgo(400 * 24 * 60 * 60), now)).toBe("last year");
});
it("phrases future timestamps as future", () => {
expect(formatRelativeTime(secondsAhead(3 * 60 * 60), now)).toBe("in 3 hours");
expect(formatRelativeTime(secondsAhead(5 * 24 * 60 * 60), now)).toBe("in 5 days");
});
it("accepts a Date, an epoch number, and an ISO string alike", () => {
const date = secondsAgo(3 * 60 * 60);
expect(formatRelativeTime(date, now)).toBe(THREE_HOURS_AGO);
expect(formatRelativeTime(date.getTime(), now)).toBe(THREE_HOURS_AGO);
expect(formatRelativeTime(date.toISOString(), now)).toBe(THREE_HOURS_AGO);
});
});
/**
* The reason this lives in the host rather than in each plugin. A hand-rolled
* "3 hours ago" ladder is English-only by construction; Intl reads the active
* locale, so the same call renders Portuguese for a pt-PT user.
*/
describe("formatRelativeTime (locale awareness)", () => {
const now = new Date("2026-07-27T12:00:00Z").getTime();
const threeHoursAgo = new Date(now - 3 * 60 * 60 * 1000);
afterAll(async () => {
await activateLocale("en");
});
it("renders in the active locale, not English", async () => {
const english = formatRelativeTime(threeHoursAgo, now);
await activateLocale("pt-pt");
const portuguese = formatRelativeTime(threeHoursAgo, now);
expect(portuguese).not.toBe(english);
expect(portuguese).toContain("3");
expect(portuguese).toMatch(/horas/i);
});
});
+73
View File
@@ -30,6 +30,79 @@ export function formatRelative(dateStr: string, now: number = Date.now()): strin
return t("common:dAgo", { diffDay });
}
/**
* Units `formatRelativeTime` picks from, largest first, with the number of
* seconds in each. Months and years are the conventional 30/365-day
* approximations — `Intl.RelativeTimeFormat` only phrases a magnitude, so
* calendar-exact boundaries are neither available nor expected here.
*/
const RELATIVE_UNITS: readonly (readonly [Intl.RelativeTimeFormatUnit, number])[] = [
["year", 365 * 24 * 60 * 60],
["month", 30 * 24 * 60 * 60],
["week", 7 * 24 * 60 * 60],
["day", 24 * 60 * 60],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
/**
* Anything closer than this to `now` reads as "now" rather than "3 seconds
* ago". Compared against raw milliseconds, deliberately: testing it against
* the already-rounded second count moved the real cutoff to 9.5s, and made it
* asymmetric, because `Math.round(-9.5)` is `-9` while `Math.round(9.5)` is
* `10` — so 9.5s in the past read "now" while 9.5s in the future read "in 10
* seconds".
*/
const JUST_NOW_SECONDS = 10;
const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
function relativeFormatter(): Intl.RelativeTimeFormat {
const locale = intlLocale();
const cached = relativeFormatters.get(locale);
if (cached) return cached;
// `numeric: "auto"` lets the CLDR data phrase the small magnitudes
// idiomatically ("yesterday" rather than "1 day ago"), which is the whole
// reason to go through Intl instead of interpolating a number.
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
relativeFormatters.set(locale, formatter);
return formatter;
}
/**
* Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
* `Intl.RelativeTimeFormat`, in the active i18next locale. Future timestamps
* come back phrased as future. Returns "" for unparseable input.
*
* Distinct from `formatRelative` above, which routes a fixed four-bucket
* ladder through the message catalog for compact UI chips. This one covers
* the full range up to years and needs no catalog keys, so it is what
* `host.utils.formatRelativeTime` exposes to plugins.
*/
export function formatRelativeTime(
value: string | number | Date,
now: number = Date.now(),
): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
const formatter = relativeFormatter();
const diffMs = date.getTime() - now;
if (Math.abs(diffMs) < JUST_NOW_SECONDS * 1000) return formatter.format(0, "second");
// Rounded, not truncated, so unit selection lands on the nearest unit —
// 59.6s reads "1 minute ago", which describes it better than "59 seconds
// ago". That rounding is intended for the unit choice; it just must not
// decide the "now" threshold above.
const diffSec = Math.round(diffMs / 1000);
const magnitude = Math.abs(diffSec);
for (const [unit, seconds] of RELATIVE_UNITS) {
if (magnitude >= seconds) return formatter.format(Math.trunc(diffSec / seconds), unit);
}
return formatter.format(0, "second");
}
export function formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
return new Intl.NumberFormat(intlLocale(), options).format(value);
}
@@ -6,6 +6,11 @@ import { pluginRegistry } from "./registry";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
/** No-op `host.toast`; these specs exercise lifecycle, never notifications. */
const NOOP_TOAST = new Proxy(() => 0, {
get: () => () => 0,
}) as unknown as PluginHostApi["toast"];
vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "" }) }));
const PLUGIN_ID = "kandev-plugin-render-dup";
@@ -31,6 +36,8 @@ function makeHostFactory(pluginId: string): PluginHostApi {
onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
toast: NOOP_TOAST,
utils: { cn: () => "", formatRelativeTime: () => "" },
storage: {
get: async () => undefined,
set: async () => ({ updatedAt: "" }),
+78
View File
@@ -5,16 +5,45 @@ import { buildHostApi } from "./host-api";
/** Curated primitives a plugin needs to build a full native-feeling page. */
const EXPECTED_UI_PRIMITIVES = [
"Accordion",
"AccordionContent",
"AccordionItem",
"AccordionTrigger",
"Alert",
"Badge",
"Button",
"Card",
"ChartContainer",
"ChartLegend",
"ChartLegendContent",
"ChartStyle",
"ChartTooltip",
"ChartTooltipContent",
"Checkbox",
"Collapsible",
"CollapsibleContent",
"CollapsibleTrigger",
"Dialog",
"DropdownMenu",
"Empty",
"EmptyContent",
"EmptyDescription",
"EmptyHeader",
"EmptyMedia",
"EmptyTitle",
"Input",
"Kbd",
"KbdGroup",
"Label",
"Pagination",
"Popover",
"PopoverAnchor",
"PopoverContent",
"PopoverDescription",
"PopoverHeader",
"PopoverTitle",
"PopoverTrigger",
"Progress",
"ScrollArea",
"Select",
"Sheet",
@@ -31,6 +60,7 @@ const EXPECTED_UI_PRIMITIVES = [
"Tabs",
"Textarea",
"Tooltip",
"TooltipProvider",
];
describe("buildHostApi — api.fetch", () => {
@@ -268,6 +298,54 @@ describe("buildHostApi — host.theme / host.onThemeChange", () => {
});
});
/**
* `toast` and `utils` are functions, so they sit beside `navigate`/`openModal`
* rather than in `ui`, which is a component map.
*/
describe("buildHostApi — host.toast / host.utils", () => {
it("exposes sonner's imperative toast, which needs no host rendering", () => {
const host = buildHostApi("jira", createAppStore());
expect(typeof host.toast).toBe("function");
for (const method of ["success", "error", "warning", "info", "dismiss"] as const) {
expect(typeof host.toast[method], `host.toast.${method}`).toBe("function");
}
});
// Scoped per plugin: an unattributed plugin error toast would land in
// kandev's own frontend error log as an application error.
it("scopes toast.error to the calling plugin rather than the app reporting seam", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const host = buildHostApi("kandev-plugin-github-status", createAppStore());
host.toast.error("Poll failed");
expect(consoleError).toHaveBeenCalledWith(
'[plugins] toast.error from "kandev-plugin-github-status":',
"Poll failed",
);
consoleError.mockRestore();
});
it("exposes cn, merging conflicting tailwind classes the way host components do", () => {
const host = buildHostApi("jira", createAppStore());
expect(typeof host.utils.cn).toBe("function");
// tailwind-merge semantics, not plain concatenation — the later padding wins.
expect(host.utils.cn("p-2", "p-4")).toBe("p-4");
const hidden = false;
expect(host.utils.cn("text-sm", hidden && "hidden", "font-bold")).toBe("text-sm font-bold");
});
it("exposes formatRelativeTime rather than leaving plugins to hand-roll one", () => {
const host = buildHostApi("jira", createAppStore());
expect(typeof host.utils.formatRelativeTime).toBe("function");
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
expect(host.utils.formatRelativeTime(threeHoursAgo)).toContain("3");
});
});
describe("buildHostApi — ui", () => {
it("exposes RichTextEditor/RichTextReadOnly for plugin notes-style UIs", () => {
const host = buildHostApi("jira", createAppStore());
+90 -2
View File
@@ -4,6 +4,7 @@
*/
import * as React from "react";
import type { StoreApi } from "zustand";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -16,7 +17,16 @@ import {
CardHeader,
CardTitle,
} from "@kandev/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
} from "@kandev/ui/chart";
import { Checkbox } from "@kandev/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import {
Dialog,
DialogClose,
@@ -34,7 +44,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@kandev/ui/empty";
import { Input } from "@kandev/ui/input";
import { Kbd, KbdGroup } from "@kandev/ui/kbd";
import { Label } from "@kandev/ui/label";
import {
Pagination,
@@ -45,6 +64,16 @@ import {
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@kandev/ui/popover";
import { Progress } from "@kandev/ui/progress";
import { ScrollArea } from "@kandev/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Separator } from "@kandev/ui/separator";
@@ -65,11 +94,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/utils";
import { Combobox } from "@/components/combobox";
import { RichTextEditor, RichTextReadOnly } from "@/components/editors/tiptap/rich-text-editor";
import { PageTopbar } from "@/components/page-topbar";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { getBackendConfig } from "@/lib/config";
import { formatRelativeTime } from "@/lib/i18n/formats";
import { createPluginToast } from "@/lib/toast/sonner";
import { generateUUID } from "@/lib/utils";
import { softNavigate } from "@/lib/routing/client-router";
import type { AppState } from "@/lib/state/store";
@@ -90,10 +122,16 @@ import {
* host instances rather than bundling their own copies — bundling is not an
* option for anything that touches React context or portals (Radix), since
* the plugin shares the host React instance and a second copy would split
* context and break refs/asChild. Pure-React libs (e.g. icon sets) bundle
* fine.
* context and break refs/asChild. The same applies to recharts behind the
* Chart* exports: it drives layout through its own context and portals
* tooltips, so a bundled second copy splits that context exactly the way a
* second Radix copy does. Pure-React libs (e.g. icon sets) bundle fine.
*/
const PLUGIN_UI: Record<string, unknown> = {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Alert,
AlertDescription,
AlertTitle,
@@ -106,7 +144,20 @@ const PLUGIN_UI: Record<string, unknown> = {
CardFooter,
CardHeader,
CardTitle,
// Chart: recharts wrappers. Plugins must use these rather than importing
// recharts themselves — same context/portal hazard as Radix, and recharts
// 2.15.4 is already a dependency of both apps/web and @kandev/ui, so using
// the host's copy costs no bundle bytes.
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
Checkbox,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Dialog,
DialogClose,
DialogContent,
@@ -120,7 +171,15 @@ const PLUGIN_UI: Record<string, unknown> = {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
Input,
Kbd,
KbdGroup,
Label,
Pagination,
PaginationContent,
@@ -129,6 +188,14 @@ const PLUGIN_UI: Record<string, unknown> = {
PaginationLink,
PaginationNext,
PaginationPrevious,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
Progress,
ScrollArea,
Select,
SelectContent,
@@ -188,6 +255,19 @@ const PLUGIN_UI: Record<string, unknown> = {
RichTextReadOnly,
};
/**
* `host.utils` — plain functions, deliberately not on `host.ui` (a component
* map). Shared rather than reimplemented per plugin: `cn` must be the host's
* so Tailwind class merging matches the components it styles, and
* `formatRelativeTime` must be the host's so a plugin's timestamps follow the
* user's locale instead of the hand-rolled English-only ladders several
* published plugins ship today.
*/
const PLUGIN_UTILS = {
cn,
formatRelativeTime,
};
/**
* Per-browser-tab id stamped on every `host.storage.set`/`delete` call and
* echoed back in the `plugin.user-state.updated` WS payload, so
@@ -250,6 +330,14 @@ export function buildHostApi(pluginId: string, storeApi: StoreApi<AppState>): Pl
onThemeChange: (listener) => subscribeToThemeChanges(listener),
navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"),
openModal: (options) => pluginModalManager.openModal(pluginId, options),
// Sonner's imperative global. The app mounts <Toaster/> once in
// app/layout.tsx, so this needs no host wiring and — unlike a rendered
// component — works from a plugin modal regardless of which providers
// that modal sits under. Scoped per plugin so `.error` logs with
// attribution rather than filing a kandev frontend error report; see
// createPluginToast.
toast: createPluginToast(pluginId),
utils: PLUGIN_UTILS,
storage: buildStorageApi(pluginId),
};
}
@@ -3,6 +3,11 @@ import { loadPlugins } from "./host";
import { pluginRegistry } from "./registry";
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
/** No-op `host.toast`; these specs exercise lifecycle, never notifications. */
const NOOP_TOAST = new Proxy(() => 0, {
get: () => () => 0,
}) as unknown as PluginHostApi["toast"];
const PLUGIN_LIFECYCLE_A_ID = "plugin-lifecycle-a";
const PLUGIN_TIMEOUT_ID = "plugin-timeout-a";
@@ -30,6 +35,8 @@ function makeHostFactory(pluginId: string): PluginHostApi {
onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
toast: NOOP_TOAST,
utils: { cn: () => "", formatRelativeTime: () => "" },
storage: {
get: async () => undefined,
set: async () => ({ updatedAt: "" }),
+7
View File
@@ -5,6 +5,11 @@ import { pluginModalManager } from "./modal-manager";
import { pluginRegistry } from "./registry";
import type { ActivePlugin, PluginHostApi, PluginRegistry } from "./types";
/** No-op `host.toast`; these specs exercise lifecycle, never notifications. */
const NOOP_TOAST = new Proxy(() => 0, {
get: () => () => 0,
}) as unknown as PluginHostApi["toast"];
let mockApiBaseUrl = "";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: mockApiBaseUrl }),
@@ -37,6 +42,8 @@ function makeHostFactory(pluginId: string): PluginHostApi {
onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
toast: NOOP_TOAST,
utils: { cn: () => "", formatRelativeTime: () => "" },
storage: {
get: async () => undefined,
set: async () => ({ updatedAt: "" }),
+45 -3
View File
@@ -371,9 +371,9 @@ export interface PluginHostApi {
};
/**
* Curated subset of `@kandev/ui` components (Button, Card, Badge, Input,
* Tabs, Dialog, Table, ...) plus first-party app UI (PageTopbar,
* TaskCreateDialog, RichTextEditor, RichTextReadOnly). See
* `lib/plugins/host-api.ts` for the full list.
* Tabs, Dialog, Table, Popover, Progress, Accordion, Chart, ...) plus
* first-party app UI (PageTopbar, TaskCreateDialog, RichTextEditor,
* RichTextReadOnly). See `lib/plugins/host-api.ts` for the full list.
*/
ui: Record<string, unknown>;
/**
@@ -400,10 +400,52 @@ export interface PluginHostApi {
* handler may call it, but it works from any plugin code path.
*/
openModal(options: PluginModalOptions): PluginModalHandle;
/**
* Sonner's imperative `toast`, scoped to this plugin. The host mounts the
* single `<Toaster/>`, so there is nothing to render:
* `host.toast.success(...)` / `.error(...)` / `.warning(...)` work from any
* plugin code path, including inside a modal.
*
* `.error` additionally logs to the browser console as
* `[plugins] toast.error from "<pluginId>":`. It does **not** file a report
* into kandev's frontend error log — that log is for kandev's own
* application errors, and a plugin toasting an expected condition (a failed
* poll, say) would otherwise record one every cycle.
*/
toast: PluginToastApi;
/** Shared helpers — plain functions, not components. See `PluginUtilsApi`. */
utils: PluginUtilsApi;
/** Authenticated, per-user key/value storage. See `PluginStorageApi`. */
storage: PluginStorageApi;
}
/**
* `host.toast` — sonner's `toast`. Typed structurally rather than as
* `typeof import("sonner").toast` so a plugin's own type-checking does not
* need sonner installed.
*/
export type PluginToastApi = {
(message: string, options?: Record<string, unknown>): string | number;
success(message: string, options?: Record<string, unknown>): string | number;
error(message: string, options?: Record<string, unknown>): string | number;
warning(message: string, options?: Record<string, unknown>): string | number;
info(message: string, options?: Record<string, unknown>): string | number;
dismiss(id?: string | number): unknown;
};
/** `host.utils` — shared helpers a plugin would otherwise reimplement. */
export interface PluginUtilsApi {
/** The host's `clsx` + `tailwind-merge` class combiner. */
cn(...inputs: unknown[]): string;
/**
* Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
* `Intl.RelativeTimeFormat` in the user's active locale. Returns "" for
* unparseable input. Prefer this over a hand-rolled ladder — those are
* English-only and go untranslated for every non-English user.
*/
formatRelativeTime(value: string | number | Date): string;
}
/**
* Registry surface passed as the first argument to `KandevPlugin.initialize`.
* Each plugin receives an instance scoped to its own pluginId — the
+53 -1
View File
@@ -28,7 +28,7 @@ vi.mock("@/lib/api/domains/frontend-error-log-api", () => ({
scheduleFrontendErrorReport: mocks.report,
}));
import { toast } from "./sonner";
import { createPluginToast, toast } from "./sonner";
describe("reporting Sonner seam", () => {
beforeEach(() => {
@@ -52,6 +52,58 @@ describe("reporting Sonner seam", () => {
});
});
// A plugin error toast is the plugin's problem, not a kandev application
// error. Reporting it would file an Error-level backend log entry per
// occurrence — every 60s for a polling plugin — with no way to tell it
// apart from a real one. (The backend also rejects any source outside
// "sonner" | "toast-provider" with 400, so attributing it there is not
// available without a cross-stack change.)
it("does not file a frontend error report for a plugin toast", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const pluginToast = createPluginToast("kandev-plugin-github-status");
const result = pluginToast.error("Poll failed");
expect(result).toBe("toast-id");
expect(mocks.upstreamError).toHaveBeenCalledOnce();
expect(mocks.report).not.toHaveBeenCalled();
expect(mocks.order).toEqual(["delegate"]);
consoleError.mockRestore();
});
it("logs a plugin toast error to the console with plugin attribution", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const pluginToast = createPluginToast("kandev-plugin-github-status");
pluginToast.error("Poll failed");
expect(consoleError).toHaveBeenCalledWith(
'[plugins] toast.error from "kandev-plugin-github-status":',
"Poll failed",
);
consoleError.mockRestore();
});
it("leaves non-error plugin toast methods delegating straight to sonner", () => {
const pluginToast = createPluginToast("kandev-plugin-github-status");
pluginToast.success("Synced");
expect(mocks.report).not.toHaveBeenCalled();
});
it("keeps reporting for first-party call sites", () => {
toast.error("Real app failure");
expect(mocks.report).toHaveBeenCalledOnce();
expect(mocks.report).toHaveBeenCalledWith({
source: "sonner",
title: "Real app failure",
description: undefined,
error: undefined,
});
});
it("prevents application call sites from bypassing the reporting seam", () => {
const webRoot = path.resolve(process.cwd());
const wrapper = path.join(webRoot, "lib", "toast", "sonner.ts");
+32
View File
@@ -23,6 +23,38 @@ export const toast = new Proxy(upstreamToast, {
},
}) as typeof upstreamToast;
/**
* `host.toast` for one plugin (see `lib/plugins/host-api.ts`). Identical to
* `toast` except `.error` logs to the console instead of filing a frontend
* error report.
*
* That endpoint is kandev's *own* diagnostics: the backend logs each report at
* Error level as "frontend error toast". A third-party plugin's error toast is
* not a kandev application error — a plugin that toasts a failed poll every 60s
* would file an application error every 60s indefinitely, indistinguishable
* from a real one. (The backend also accepts only a closed set of sources, so
* a per-plugin `source` is rejected with 400 rather than attributed.)
*
* Plugin failures go to the console under the `[plugins]` prefix instead,
* matching every other plugin extension point — `PluginErrorBoundary`,
* `dispatchToPluginWsHandlers`, and the `onThemeChange` listener guard.
*/
export function createPluginToast(pluginId: string): typeof upstreamToast {
const pluginError = (
...args: Parameters<typeof upstreamToast.error>
): ReturnType<typeof upstreamToast.error> => {
const result = upstreamToast.error(...args);
console.error(`[plugins] toast.error from "${pluginId}":`, args[0]);
return result;
};
return new Proxy(upstreamToast, {
get(target, property, receiver) {
if (property === "error") return pluginError;
return Reflect.get(target, property, receiver);
},
}) as typeof upstreamToast;
}
function ownDataValue(value: unknown, property: string): unknown {
if (!value || typeof value !== "object") return undefined;
try {
+64 -3
View File
@@ -75,6 +75,14 @@ interface PluginHostApi {
// (mounted once at the app root, isolated behind its own error boundary).
// Independent of keybindings — any plugin code path may call it.
openModal(options: PluginModalOptions): PluginModalHandle;
// Sonner's imperative toast. The host mounts the single <Toaster/>, so
// there is nothing to render and nothing to wire — and because it is
// imperative rather than a component, it works from inside a plugin modal
// regardless of which providers that modal sits under.
toast: PluginToastApi;
// Shared helpers — plain functions, so they live here rather than in `ui`
// (a component map). See the "host.utils" section below.
utils: PluginUtilsApi;
// Authenticated, per-user key/value storage backed by
// /api/plugins/{id}/user-state/... — see the "host.storage" section below.
// Requires the plugin manifest to declare capabilities.user_state: true.
@@ -162,10 +170,13 @@ interface PluginModalHandle {
}
```
`host.ui` contents: shadcn primitives (Alert*, Badge, Button, Card*, Checkbox,
Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*,
`host.ui` contents: shadcn primitives (Accordion*, Alert*, Badge, Button,
Card*, Checkbox, Collapsible*, Dialog*, DropdownMenu*, Empty*, Input, Kbd,
KbdGroup, Label, Pagination*, Popover*, Progress, ScrollArea, Select*,
Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea,
Tooltip*, including `TooltipProvider`) plus first-party app UI: `PageTopbar` (the kandev title bar, for
Tooltip*, including `TooltipProvider`), the recharts wrappers (`ChartContainer`,
`ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`,
`ChartStyle`), plus first-party app UI: `PageTopbar` (the kandev title bar, for
routes that opt out of the default chrome and own their layout),
`TaskCreateDialog` (kandev's real create-task modal, prefilled via
`initialValues`), `Combobox` (the app's Command+Popover picker), and
@@ -184,6 +195,56 @@ The host wraps plugin routes, slots, and `openModal` content in a
`delayDuration`/`skipDelayDuration` over a dense cluster of tooltips; Radix
supports nesting it.
**Charts use the host's recharts.** `recharts` is already a dependency of both
`apps/web` and `@kandev/ui`, so the `Chart*` exports add no bundle weight — and
a plugin must never bundle its own copy. recharts drives layout through its own
React context and portals its tooltips, so a second copy splits that context
exactly the way a second Radix copy does: charts render at zero size, tooltips
attach to the wrong tree, and responsive containers stop resizing. Compose the
`Chart*` wrappers with the chart primitives they hand you rather than importing
`recharts` in plugin code.
### `host.toast` and `host.utils`
```ts
// Sonner's imperative toast — the host owns the single <Toaster/>, so there
// is nothing to render and it works from any plugin code path, modals
// included.
host.toast.success("Synced 12 issues");
host.toast.error("Sync failed");
interface PluginToastApi {
(message: string, options?: Record<string, unknown>): string | number;
success(message: string, options?: Record<string, unknown>): string | number;
// Renders the same toast as any other variant, and additionally logs
// `[plugins] toast.error from "<pluginId>":` to the browser console.
// It does NOT file a report into kandev's frontend error log — that log is
// for kandev's own application errors, and a plugin toasting an expected
// condition (a failed poll, say) would otherwise record an Error-level
// entry every cycle. Console is where every plugin failure surfaces.
error(message: string, options?: Record<string, unknown>): string | number;
warning(message: string, options?: Record<string, unknown>): string | number;
info(message: string, options?: Record<string, unknown>): string | number;
// Dismisses one toast by the id a variant returned, or all of them when
// called with no argument.
dismiss(id?: string | number): unknown;
}
interface PluginUtilsApi {
// The host's clsx + tailwind-merge combiner, so class merging matches the
// components it styles.
cn(...inputs: unknown[]): string;
// Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
// Intl.RelativeTimeFormat in the user's active locale; "" for unparseable
// input. Prefer it over a hand-rolled ladder, which is English-only by
// construction and goes untranslated for every non-English user.
formatRelativeTime(value: string | number | Date): string;
}
```
Both are functions, so they sit beside `navigate`/`openModal` rather than in
`ui`, which is a component map.
### `host.ui.RichTextEditor` / `host.ui.RichTextReadOnly`
Pixel-identical to the Plan panel's markdown editor (paste handling, slash