diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
index 3da8d92604..3f3b592ded 100644
--- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
+++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js
@@ -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",
+ ),
);
}
diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts
index 0795674717..d693f12686 100644
--- a/apps/web/e2e/tests/plugins/plugins.spec.ts
+++ b/apps/web/e2e/tests/plugins/plugins.spec.ts
@@ -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 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 's class. jsdom's MutationObserver is a shim, so
// only a browser proves a real theme flip reaches a plugin subscriber.
diff --git a/apps/web/lib/i18n/formats.test.ts b/apps/web/lib/i18n/formats.test.ts
index 5f0d95ad4b..93e5a84c95 100644
--- a/apps/web/lib/i18n/formats.test.ts
+++ b/apps/web/lib/i18n/formats.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/lib/i18n/formats.ts b/apps/web/lib/i18n/formats.ts
index 3e2ab8bd32..dbba95d700 100644
--- a/apps/web/lib/i18n/formats.ts
+++ b/apps/web/lib/i18n/formats.ts
@@ -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();
+
+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);
}
diff --git a/apps/web/lib/plugins/duplicate-render.test.tsx b/apps/web/lib/plugins/duplicate-render.test.tsx
index 26b0e51102..650b5c9024 100644
--- a/apps/web/lib/plugins/duplicate-render.test.tsx
+++ b/apps/web/lib/plugins/duplicate-render.test.tsx
@@ -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: "" }),
diff --git a/apps/web/lib/plugins/host-api.test.ts b/apps/web/lib/plugins/host-api.test.ts
index 17370f0b5f..9468c6fe23 100644
--- a/apps/web/lib/plugins/host-api.test.ts
+++ b/apps/web/lib/plugins/host-api.test.ts
@@ -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());
diff --git a/apps/web/lib/plugins/host-api.ts b/apps/web/lib/plugins/host-api.ts
index 96eceb6f6d..b6d5230a1d 100644
--- a/apps/web/lib/plugins/host-api.ts
+++ b/apps/web/lib/plugins/host-api.ts
@@ -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 = {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
Alert,
AlertDescription,
AlertTitle,
@@ -106,7 +144,20 @@ const PLUGIN_UI: Record = {
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 = {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
+ Empty,
+ EmptyContent,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
Input,
+ Kbd,
+ KbdGroup,
Label,
Pagination,
PaginationContent,
@@ -129,6 +188,14 @@ const PLUGIN_UI: Record = {
PaginationLink,
PaginationNext,
PaginationPrevious,
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+ Progress,
ScrollArea,
Select,
SelectContent,
@@ -188,6 +255,19 @@ const PLUGIN_UI: Record = {
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): 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 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),
};
}
diff --git a/apps/web/lib/plugins/host-lifecycle.test.ts b/apps/web/lib/plugins/host-lifecycle.test.ts
index 115c9c41cb..abd3e896a8 100644
--- a/apps/web/lib/plugins/host-lifecycle.test.ts
+++ b/apps/web/lib/plugins/host-lifecycle.test.ts
@@ -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: "" }),
diff --git a/apps/web/lib/plugins/host.test.ts b/apps/web/lib/plugins/host.test.ts
index 6ca8038824..1261c7a9aa 100644
--- a/apps/web/lib/plugins/host.test.ts
+++ b/apps/web/lib/plugins/host.test.ts
@@ -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: "" }),
diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts
index b89ff110ea..55b6e59ac2 100644
--- a/apps/web/lib/plugins/types.ts
+++ b/apps/web/lib/plugins/types.ts
@@ -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;
/**
@@ -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 ``, 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 "":`. 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 | number;
+ success(message: string, options?: Record): string | number;
+ error(message: string, options?: Record): string | number;
+ warning(message: string, options?: Record): string | number;
+ info(message: string, options?: Record): 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
diff --git a/apps/web/lib/toast/sonner.test.ts b/apps/web/lib/toast/sonner.test.ts
index b4b0a2077d..ee2dc1ee0e 100644
--- a/apps/web/lib/toast/sonner.test.ts
+++ b/apps/web/lib/toast/sonner.test.ts
@@ -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");
diff --git a/apps/web/lib/toast/sonner.ts b/apps/web/lib/toast/sonner.ts
index 52f90c6113..30211e8c0f 100644
--- a/apps/web/lib/toast/sonner.ts
+++ b/apps/web/lib/toast/sonner.ts
@@ -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
+ ): ReturnType => {
+ 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 {
diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md
index 91dab4f60c..134e4d317f 100644
--- a/docs/plans/plugins/PLUGIN-API.md
+++ b/docs/plans/plugins/PLUGIN-API.md
@@ -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 , 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 , 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 | number;
+ success(message: string, options?: Record): string | number;
+ // Renders the same toast as any other variant, and additionally logs
+ // `[plugins] toast.error from "":` 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 | number;
+ warning(message: string, options?: Record): string | number;
+ info(message: string, options?: Record): 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