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