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 41efdfb9e8..3da8d92604 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -6,8 +6,13 @@ * registers a nav item, a top-level route, a `task-sidebar` slot component, * a `main-top-bar` slot component, a `task.created` WS handler, and the * `open-demo` keybinding (declared in manifest.yaml's `ui.keybindings`, - * default `mod+shift+j`) which opens a `host.openModal(...)` demo modal. - * Uses only host.React/host.jsx. + * default `mod+shift+j`) which opens a `host.openModal(...)` demo modal + * containing a `host.ui` Tooltip. Uses only host.React/host.jsx/host.ui. + * + * 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). * * It also registers the plugin-hooks surface this fixture exists to drive * end to end: a "Notes" task panel (registerTaskPanel, mobile-enabled) that @@ -54,14 +59,44 @@ initialize: function (registry, host) { var React = host.React; var jsx = host.jsx; + var ui = host.ui; + + // Reads host.theme once on mount, then tracks it purely through + // host.onThemeChange — so the readout only stays correct if the + // subscription actually fires. Also counts notifications, proving the + // host does not re-notify on unrelated class churn. + function useHostTheme() { + var state = React.useState(function () { + return { theme: host.theme, changes: 0 }; + }); + var value = state[0]; + var setValue = state[1]; + React.useEffect(function () { + return host.onThemeChange(function (next) { + setValue(function (prev) { + return { theme: next, changes: prev.changes + 1 }; + }); + }); + }, []); + return value; + } function PluginPage() { var count = useCounter(React); + var themeState = useHostTheme(); return jsx( "div", { id: "hello-plugin-page-root" }, jsx("h1", { id: "hello-plugin-page" }, "Hello E2E"), jsx("span", { id: "hello-task-counter" }, String(count)), + jsx( + "span", + { + "data-testid": "hello-theme-readout", + "data-theme-changes": String(themeState.changes), + }, + themeState.theme, + ), ); } @@ -242,10 +277,25 @@ registry.registerKeybinding("open-demo", function () { function DemoModalContent() { + // The Tooltip is the point of this modal in e2e: PluginModalHost + // mounts outside AppShell, so without its own TooltipProvider a + // Tooltip here throws on render and the error boundary swallows the + // whole modal. jsdom cannot open a Radix tooltip from synthetic + // hover, so real pointer hover is only assertable here. return jsx( "div", { id: "hello-demo-modal", "data-testid": "hello-demo-modal" }, "Hello from the plugin modal", + jsx( + ui.Tooltip, + null, + jsx( + ui.TooltipTrigger, + { "data-testid": "hello-modal-tooltip-trigger" }, + "hover me", + ), + jsx(ui.TooltipContent, null, "Tooltip inside a plugin modal"), + ), ); } host.openModal({ diff --git a/apps/backend/internal/backendapp/plugins_version_test.go b/apps/backend/internal/backendapp/plugins_version_test.go new file mode 100644 index 0000000000..66e4e94712 --- /dev/null +++ b/apps/backend/internal/backendapp/plugins_version_test.go @@ -0,0 +1,71 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/events/bus" +) + +// TestProvideServicesWiresPluginsKandevVersion pins the production wiring of +// the running build version into the plugin service. +// +// internal/plugins has always been able to enforce a package's +// manifest.min_kandev_version, but checkMinKandevVersion short-circuits to +// nil while s.kandevVersion is empty — so for as long as SetKandevVersion had +// no production caller, the whole mechanism was a silent no-op in every +// shipped build while its unit tests stayed green. Only a test that goes +// through provideServices can catch that regressing again. +func TestProvideServicesWiresPluginsKandevVersion(t *testing.T) { + const wantVersion = "9.8.7" + + services, _ := provideTestServices(t, wantVersion) + if services.Plugins == nil { + t.Fatal("provideServices returned a nil Plugins service") + } + if got := services.Plugins.KandevVersion(); got != wantVersion { + t.Fatalf("Plugins.KandevVersion() = %q, want %q — min_kandev_version is unenforced without it", got, wantVersion) + } +} + +// provideTestServices boots the real service graph over a temp-dir SQLite +// database, the same way Run does, and returns the resulting services. +func provideTestServices(t *testing.T, version string) (*Services, *config.Config) { + t.Helper() + + cfg := &config.Config{ + HomeDir: t.TempDir(), + Database: config.DatabaseConfig{Driver: "sqlite"}, + } + log := newTestLogger() + + pool, repos, cleanups, err := provideRepositories(context.Background(), cfg, log, version) + if err != nil { + t.Fatalf("provideRepositories: %v", err) + } + t.Cleanup(func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] != nil { + _ = cleanups[i]() + } + } + }) + + agentRegistry, registryCleanup, err := registry.Provide(log) + if err != nil { + t.Fatalf("registry.Provide: %v", err) + } + t.Cleanup(func() { + if registryCleanup != nil { + _ = registryCleanup() + } + }) + + services, _, err := provideServices(cfg, log, repos, pool, bus.NewMemoryEventBus(log), agentRegistry, version) + if err != nil { + t.Fatalf("provideServices: %v", err) + } + return services, cfg +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index c777ad52d8..62f0101463 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -139,6 +139,11 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, gitlabSvc, workflowSvc, taskSvc, log) pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log) if pluginsSvc != nil { + // The ldflags-injected build version, so Install can enforce a + // package's manifest.min_kandev_version. This is the only production + // caller; without it the check stays a no-op. An un-stamped local + // build passes "dev", which the service treats as "don't enforce". + pluginsSvc.SetKandevVersion(version) pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics), taskSvc, pluginsTaskWriterAdapter{svc: taskSvc}) } shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version) diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index b27c1803b4..a8026cab2a 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -122,9 +122,9 @@ type Service struct { // kandevVersion is the currently running kandev build version, used to // enforce a package's manifest.min_kandev_version at Install (see - // SetKandevVersion / checkMinKandevVersion). Empty (the default) means - // no enforcement — no caller currently wires this in production; see - // SetKandevVersion's doc comment. + // SetKandevVersion / checkMinKandevVersion). Empty (the default, e.g. in + // tests that construct a Service directly) or the "dev" sentinel means no + // enforcement; backendapp wires the real ldflags-injected version. kandevVersion string httpClient *http.Client @@ -346,15 +346,21 @@ func (s *Service) authLoginBridge() AuthLoginBridge { // SetKandevVersion wires the currently running kandev build version, // enabling Install to enforce a package's manifest.min_kandev_version // (checkMinKandevVersion): a package requiring a newer kandev is rejected -// rather than installed and left to fail confusingly at spawn time. Not -// currently called by Provide — the running build version needs to be -// threaded down from internal/backendapp's ldflags-injected Version, which -// is outside this package; until a caller wires it, min_kandev_version -// remains parsed and stored but unenforced (the pre-existing behavior). +// rather than installed and left to fail confusingly at spawn time. +// Called once during startup wiring from internal/backendapp, which owns the +// ldflags-injected Version. Passing DevKandevVersion (an un-stamped local +// build) leaves the check a no-op — see checkMinKandevVersion. func (s *Service) SetKandevVersion(v string) { s.kandevVersion = v } +// KandevVersion returns the running build version wired via +// SetKandevVersion, or "" when startup wiring never supplied one (in which +// case min_kandev_version is not enforced). +func (s *Service) KandevVersion() string { + return s.kandevVersion +} + // SetRuntime wires the runtime.Manager Provide constructed. func (s *Service) SetRuntime(rt PluginRuntime) { s.runtime = rt @@ -785,14 +791,21 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro return installed, activateErr } +// DevKandevVersion is the version string an un-stamped local build carries +// (cmd/kandev's `Version` default, mirrored by internal/system/updates' +// devVersion). It sorts meaninglessly against real semver, and a developer +// running from source must still be able to install a package that declares +// a min_kandev_version, so it disables the check entirely. +const DevKandevVersion = "dev" + // checkMinKandevVersion rejects a package whose manifest declares a // min_kandev_version newer than the currently running kandev build // (manifest.CompareVersions). A no-op (nil error) when either side is -// unset: minVersion == "" (the manifest doesn't declare one, the common -// case today) or s.kandevVersion == "" (no running version wired via -// SetKandevVersion). +// unset or unusable: minVersion == "" (the manifest doesn't declare one, +// the common case today), s.kandevVersion == "" (no running version wired +// via SetKandevVersion), or s.kandevVersion == DevKandevVersion. func (s *Service) checkMinKandevVersion(minVersion string) error { - if minVersion == "" || s.kandevVersion == "" { + if minVersion == "" || s.kandevVersion == "" || s.kandevVersion == DevKandevVersion { return nil } if manifest.CompareVersions(s.kandevVersion, minVersion) < 0 { diff --git a/apps/backend/internal/plugins/service_install_test.go b/apps/backend/internal/plugins/service_install_test.go index b1c51c0cc4..01b21e65ad 100644 --- a/apps/backend/internal/plugins/service_install_test.go +++ b/apps/backend/internal/plugins/service_install_test.go @@ -259,6 +259,36 @@ func TestServiceInstall_AllowsPackageAtOrBelowRunningKandevVersion(t *testing.T) } } +// TestServiceInstall_NoEnforcementOnDevBuild pins the dev-build escape +// hatch. An un-stamped local build reports the "dev" sentinel, and a +// developer running from source must still be able to install any package — +// exactly as before this check was wired up in production. +// +// The escape hatch has to be explicit rather than left to +// manifest.CompareVersions, because "dev" is not a version and that function +// falls back to a plain strings.Compare when a segment won't parse. Which +// side wins then is an accident of the alphabet: "dev" happens to sort above +// a bare "999.0.0", but below anything starting past 'd' — and +// min_kandev_version is a free-form manifest string, so a "v"-prefixed value +// like "v1.0.0" is a spelling an author can (and does) write. Without the +// sentinel skip that spelling locks every dev build out of the plugin. +func TestServiceInstall_NoEnforcementOnDevBuild(t *testing.T) { + for _, minVersion := range []string{"999.0.0", "v1.0.0"} { + t.Run(minVersion, func(t *testing.T) { + svc, _, _ := newTestService(t) + svc.SetKandevVersion(DevKandevVersion) + + rec, err := svc.Install(context.Background(), minKandevVersionPackage(t, "kandev-plugin-slack", "1.0.0", minVersion)) + if err != nil { + t.Fatalf("Install() unexpected error on a dev build (min_kandev_version %q): %v", minVersion, err) + } + if rec.Status != StatusActive { + t.Fatalf("Install() Status = %q, want %q", rec.Status, StatusActive) + } + }) + } +} + // TestServiceInstall_NoEnforcementWithoutKandevVersionWired pins the // no-op default: a Service that never called SetKandevVersion (e.g. every // other test in this file, and any caller that hasn't wired it yet) must diff --git a/apps/web/components/plugins/plugin-modal-host.test.tsx b/apps/web/components/plugins/plugin-modal-host.test.tsx index 0bced99e69..6852bf5bda 100644 --- a/apps/web/components/plugins/plugin-modal-host.test.tsx +++ b/apps/web/components/plugins/plugin-modal-host.test.tsx @@ -1,5 +1,6 @@ -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { pluginModalManager } from "@/lib/plugins/modal-manager"; import { PluginModalHost } from "./plugin-modal-host"; @@ -45,3 +46,51 @@ describe("PluginModalHost", () => { expect(screen.queryByTestId("modal-content")).toBeNull(); }); }); + +/** + * The host mounts as a sibling of `` (src/main.tsx), so it is + * outside the app-wide `TooltipProvider` in app/layout.tsx. Without a + * provider of its own, a `Tooltip` anywhere in plugin modal content throws on + * render and the whole modal is lost to the error boundary. + * + * jsdom does not reliably open a Radix tooltip from synthetic hover (see + * apps/web/CLAUDE.md), so the open assertion goes through focus. Pointer + * hover is a Playwright concern. + */ +describe("PluginModalHost — tooltips in modal content", () => { + afterEach(() => { + cleanup(); + cleanupModals("plugin-a"); + }); + + function TooltipContentComponent() { + return ( + + Trigger + Helpful hint + + ); + } + + it("renders modal content containing a Tooltip without throwing", () => { + pluginModalManager.openModal("plugin-a", { + title: "Tooltip Modal", + content: TooltipContentComponent, + }); + + render(); + + expect(screen.getByTestId("tooltip-trigger")).not.toBeNull(); + }); + + it("opens the tooltip on focus, proving a TooltipProvider is in scope", async () => { + pluginModalManager.openModal("plugin-a", { content: TooltipContentComponent }); + + render(); + fireEvent.focus(screen.getByTestId("tooltip-trigger")); + + await waitFor(() => { + expect(screen.getAllByText("Helpful hint").length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/web/components/plugins/plugin-modal-host.tsx b/apps/web/components/plugins/plugin-modal-host.tsx index 05da40ee59..ac92b06957 100644 --- a/apps/web/components/plugins/plugin-modal-host.tsx +++ b/apps/web/components/plugins/plugin-modal-host.tsx @@ -1,6 +1,7 @@ "use client"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { TooltipProvider } from "@kandev/ui/tooltip"; import { pluginModalManager, usePluginModals, @@ -61,15 +62,22 @@ function PluginModalInstance({ modal }: { modal: OpenPluginModal }) { * Renders every open plugin modal (`host.openModal(...)`) in a `@kandev/ui` * `Dialog`, each isolated behind its own `PluginErrorBoundary`. Mounted once * at the app root, alongside ``. + * + * The `TooltipProvider` is not redundant with the app-wide one in + * `app/layout.tsx`: this host mounts as a *sibling* of `` (see + * `src/main.tsx`), so it is outside that provider's subtree and a `Tooltip` + * inside plugin modal content would throw for want of a provider. Radix + * allows nesting, so this stays correct if the host is ever moved inside the + * shell. */ export function PluginModalHost() { const modals = usePluginModals(); if (modals.length === 0) return null; return ( - <> + {modals.map((modal) => ( ))} - + ); } diff --git a/apps/web/components/settings/plugins/use-plugin-actions.ts b/apps/web/components/settings/plugins/use-plugin-actions.ts index 425509fb60..78779e72a7 100644 --- a/apps/web/components/settings/plugins/use-plugin-actions.ts +++ b/apps/web/components/settings/plugins/use-plugin-actions.ts @@ -7,7 +7,6 @@ import { toast } from "@/lib/toast/sonner"; import { t } from "@/lib/i18n"; import type { StoreApi } from "zustand"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { useTheme } from "@/components/theme/app-theme"; import { disablePlugin, enablePlugin, @@ -57,7 +56,6 @@ function withStatus(plugin: PluginRecord, status: PluginStatus): PluginRecord { async function loadIfActive( record: PluginRecord, storeApi: StoreApi, - theme: "light" | "dark", evictCache: boolean, ) { if (record.status !== "active") return; @@ -68,7 +66,7 @@ async function loadIfActive( } else { unloadPlugin(record.id, { transition: "reload" }); } - await loadPlugins([active], (pluginId) => buildHostApi(pluginId, storeApi, theme)); + await loadPlugins([active], (pluginId) => buildHostApi(pluginId, storeApi)); } /** @@ -79,7 +77,6 @@ async function loadIfActive( */ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) { const storeApi = useAppStoreApi(); - const { resolvedTheme } = useTheme(); const [busyId, setBusyId] = useState(null); const handleEnable = async (plugin: PluginRecord) => { @@ -88,7 +85,7 @@ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) { await enablePlugin(plugin.id); const updated = withStatus(plugin, "active"); upsertPlugin(updated); - await loadIfActive(updated, storeApi, resolvedTheme, false); + await loadIfActive(updated, storeApi, false); } catch (err) { try { const refreshed = await getPlugin(plugin.id, { cache: "no-store" }); @@ -197,7 +194,6 @@ function useUninstallAction(removePlugin: (id: string) => void) { */ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) { const storeApi = useAppStoreApi(); - const { resolvedTheme } = useTheme(); const [installOpen, setInstallOpenState] = useState(false); const [installBusy, setInstallBusy] = useState(false); const [installError, setInstallError] = useState(null); @@ -212,7 +208,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) { // "installed" toast, so it takes priority over the success toast. const afterInstall = async ({ plugin, warning }: InstallResult) => { upsertPlugin(plugin); - await loadIfActive(plugin, storeApi, resolvedTheme, true); + await loadIfActive(plugin, storeApi, true); if (warning) { toast.warning(warning); } else { diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts index cae0d2910e..0795674717 100644 --- a/apps/web/e2e/tests/plugins/plugins.spec.ts +++ b/apps/web/e2e/tests/plugins/plugins.spec.ts @@ -446,7 +446,10 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () = await testPage.keyboard.press("ControlOrMeta+Shift+J"); const modal = testPage.getByTestId("hello-demo-modal"); await expect(modal).toBeVisible(); - await expect(modal).toHaveText("Hello from the plugin modal"); + // toContainText, not toHaveText: the modal body also carries the tooltip + // trigger exercised by the hover spec below, and this assertion is about + // the keybinding reaching host.openModal, not the body's exact contents. + await expect(modal).toContainText("Hello from the plugin modal"); // --- The host Dialog's built-in close button dismisses the (dismissible // by default) modal. --- @@ -454,6 +457,77 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () = await expect(modal).not.toBeVisible(); }); + // `PluginModalHost` mounts as a sibling of `` (src/main.tsx), so it + // is outside the app-wide TooltipProvider in app/layout.tsx and needs its + // own. The unit test for this asserts via focus, because jsdom does not + // reliably open a Radix tooltip from synthetic hover (apps/web/CLAUDE.md) — + // real pointer hover, and the portaled role="tooltip" it produces, are only + // assertable in a browser. + test("a Tooltip inside host.openModal content opens on real pointer hover", 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 expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible({ + timeout: 15_000, + }); + + await testPage.keyboard.press("ControlOrMeta+Shift+J"); + const modal = testPage.getByTestId("hello-demo-modal"); + await expect(modal).toBeVisible(); + + // Without a provider in scope the Tooltip throws during render and + // PluginErrorBoundary swallows the entire modal body, so the trigger + // being present is itself part of the assertion. + const trigger = testPage.getByTestId("hello-modal-tooltip-trigger"); + await expect(trigger).toBeVisible(); + + await trigger.hover(); + await expect( + testPage.getByRole("tooltip").filter({ hasText: "Tooltip inside a plugin modal" }), + ).toBeVisible(); + }); + + // 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. + test("host.onThemeChange fires in a real browser when the app theme flips", 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(); + + // The readout seeds from host.theme once, then only ever updates from an + // onThemeChange notification — so a stale value here means the + // subscription never fired. + const readout = testPage.getByTestId("hello-theme-readout"); + await expect(readout).toBeVisible({ timeout: 15_000 }); + const before = await readout.textContent(); + expect(before === "light" || before === "dark").toBe(true); + + // Flip through the app's own command, not by poking the DOM, so this + // exercises AppThemeProvider the way a user would. + const target = before === "dark" ? "Switch to Light Mode" : "Switch to Dark Mode"; + await testPage.keyboard.press("ControlOrMeta+k"); + await testPage.getByRole("option", { name: target }).click(); + + await expect(readout).toHaveText(before === "dark" ? "light" : "dark"); + await expect(readout).toHaveAttribute("data-theme-changes", "1"); + }); + test("settings page: schema-driven form, secret masking, and Host GetConfig delivery", async ({ testPage, apiClient, diff --git a/apps/web/lib/plugins/boot.ts b/apps/web/lib/plugins/boot.ts index 9a7f5816cd..afd5fa64fc 100644 --- a/apps/web/lib/plugins/boot.ts +++ b/apps/web/lib/plugins/boot.ts @@ -12,11 +12,6 @@ import { installPluginGlobal, loadPlugins } from "./host"; /** Store instances that have already had `loadPlugins` triggered — boot is a one-shot per store. */ const bootedStores = new WeakSet>(); -/** Reads the resolved light/dark theme applied to the document by AppThemeProvider. */ -function readResolvedTheme(win: Window = window): "light" | "dark" { - return win.document.documentElement.classList.contains("dark") ? "dark" : "light"; -} - /** * Installs `window.registerKandevPlugin` and loads every plugin in * `bootPayload.plugins`, building each one's `PluginHostApi` from `storeApi`. @@ -35,6 +30,5 @@ export function bootPlugins( bootedStores.add(storeApi); installPluginGlobal(); - const theme = readResolvedTheme(); - void loadPlugins(bootPayload.plugins, (pluginId) => buildHostApi(pluginId, storeApi, theme)); + void loadPlugins(bootPayload.plugins, (pluginId) => buildHostApi(pluginId, storeApi)); } diff --git a/apps/web/lib/plugins/duplicate-render.test.tsx b/apps/web/lib/plugins/duplicate-render.test.tsx index 96f68c1299..26b0e51102 100644 --- a/apps/web/lib/plugins/duplicate-render.test.tsx +++ b/apps/web/lib/plugins/duplicate-render.test.tsx @@ -28,6 +28,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { api: { fetch: async () => new Response(), baseUrl: "" }, ui: {}, theme: "light", + onThemeChange: () => () => {}, navigate: () => {}, openModal: () => ({ close: () => {} }), storage: { diff --git a/apps/web/lib/plugins/host-api.test.ts b/apps/web/lib/plugins/host-api.test.ts index 71f8aea498..17370f0b5f 100644 --- a/apps/web/lib/plugins/host-api.test.ts +++ b/apps/web/lib/plugins/host-api.test.ts @@ -44,7 +44,7 @@ describe("buildHostApi — api.fetch", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); await host.api.fetch("/issues", { method: "POST" }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -57,7 +57,7 @@ describe("buildHostApi — api.fetch", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); await host.api.fetch("/issues"); const [, init] = fetchMock.mock.calls[0]; @@ -68,7 +68,7 @@ describe("buildHostApi — api.fetch", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); await host.api.fetch("issues"); const [url] = fetchMock.mock.calls[0]; @@ -82,7 +82,7 @@ describe("buildHostApi", () => { }); it("exposes the host React instance and a jsx alias for React.createElement", () => { - const host = buildHostApi("jira", createAppStore(), "dark"); + const host = buildHostApi("jira", createAppStore()); expect(host.React).toBe(React); expect(host.jsx).toBe(React.createElement); @@ -94,7 +94,7 @@ describe("buildHostApi", () => { const setStateSpy = vi.spyOn(store, "setState"); const subscribeSpy = vi.spyOn(store, "subscribe"); - const host = buildHostApi("jira", store, "dark"); + const host = buildHostApi("jira", store); expect(host.store.getState()).toBe(store.getState()); host.store.setState({}); @@ -107,10 +107,9 @@ describe("buildHostApi", () => { unsubscribe(); }); - it("exposes the requested theme and a curated ui component subset", () => { - const host = buildHostApi("jira", createAppStore(), "dark"); + it("exposes a curated ui component subset", () => { + const host = buildHostApi("jira", createAppStore()); - expect(host.theme).toBe("dark"); // Expanded primitive set for full native-feeling plugin pages. for (const name of EXPECTED_UI_PRIMITIVES) { expect(host.ui[name], `host.ui.${name}`).toBeDefined(); @@ -118,14 +117,14 @@ describe("buildHostApi", () => { }); it("exposes first-party app components for native flows and page chrome", () => { - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); expect(host.ui.PageTopbar).toBeDefined(); expect(host.ui.TaskCreateDialog).toBeDefined(); expect(host.ui.Combobox).toBeDefined(); }); it("exposes navigate() that soft-navigates via history push/replace", () => { - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); const pushSpy = vi.spyOn(window.history, "pushState"); const replaceSpy = vi.spyOn(window.history, "replaceState"); @@ -148,18 +147,18 @@ describe("buildHostApi", () => { }); it("exposes the backend API origin on api.baseUrl", () => { - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); expect(typeof host.api.baseUrl).toBe("string"); }); it("sets pluginId on the returned host api", () => { - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); expect(host.pluginId).toBe("jira"); }); it("routes openModal to the modal manager, scoped to this plugin's id", async () => { const { pluginModalManager } = await import("./modal-manager"); - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); const before = pluginModalManager.getSnapshot().length; const handle = host.openModal({ content: () => null, title: "Test" }); @@ -176,9 +175,102 @@ describe("buildHostApi", () => { }); }); +/** + * jsdom delivers MutationObserver records on a microtask, so awaiting an + * already-resolved promise is enough to let a pending class mutation land. + */ +function flushMutationObservers(): Promise { + return Promise.resolve(); +} + +/** + * `host` is built once per plugin load, so a theme captured into it at boot + * can never follow a light/dark switch. These pin the live-read contract and + * the change notification a canvas-painting plugin needs on top of it. + */ +describe("buildHostApi — host.theme / host.onThemeChange", () => { + afterEach(() => { + document.documentElement.classList.remove("dark", "light"); + }); + + it("reads the resolved theme live rather than freezing it at build time", () => { + document.documentElement.classList.remove("dark"); + const host = buildHostApi("jira", createAppStore()); + expect(host.theme).toBe("light"); + + document.documentElement.classList.add("dark"); + expect(host.theme).toBe("dark"); + + document.documentElement.classList.remove("dark"); + expect(host.theme).toBe("light"); + }); + + it("notifies onThemeChange subscribers once per flip and stops after unsubscribe", async () => { + document.documentElement.classList.remove("dark"); + const host = buildHostApi("jira", createAppStore()); + const listener = vi.fn(); + const unsubscribe = host.onThemeChange(listener); + + document.documentElement.classList.add("dark"); + await flushMutationObservers(); + expect(listener).toHaveBeenCalledExactlyOnceWith("dark"); + + unsubscribe(); + document.documentElement.classList.remove("dark"); + await flushMutationObservers(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + // Cross-plugin fan-out: one buggy plugin must not silently stop theme + // updates for every other plugin. The throwing listener is registered + // FIRST so an unguarded loop would abort before reaching the healthy one. + it("keeps notifying later listeners when an earlier one throws", async () => { + document.documentElement.classList.remove("dark"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const host = buildHostApi("jira", createAppStore()); + + const boom = vi.fn(() => { + throw new Error("plugin theme listener blew up"); + }); + const healthy = vi.fn(); + const unsubscribeBoom = host.onThemeChange(boom); + const unsubscribeHealthy = host.onThemeChange(healthy); + + document.documentElement.classList.add("dark"); + await flushMutationObservers(); + + expect(boom).toHaveBeenCalledTimes(1); + expect(healthy).toHaveBeenCalledExactlyOnceWith("dark"); + expect(consoleError).toHaveBeenCalledWith( + "[plugins] theme change listener threw:", + expect.any(Error), + ); + + unsubscribeBoom(); + unsubscribeHealthy(); + consoleError.mockRestore(); + }); + + it("ignores class mutations that leave the resolved theme unchanged", async () => { + document.documentElement.classList.add("dark"); + const host = buildHostApi("jira", createAppStore()); + const listener = vi.fn(); + const unsubscribe = host.onThemeChange(listener); + + // Unrelated class churn on — the rendering-engine marker, a + // transition-suppression class, etc. — must not wake every plugin. + document.documentElement.classList.add("some-unrelated-class"); + await flushMutationObservers(); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + document.documentElement.classList.remove("some-unrelated-class"); + }); +}); + describe("buildHostApi — ui", () => { it("exposes RichTextEditor/RichTextReadOnly for plugin notes-style UIs", () => { - const host = buildHostApi("jira", createAppStore(), "light"); + const host = buildHostApi("jira", createAppStore()); expect(host.ui.RichTextEditor).toBeDefined(); expect(host.ui.RichTextReadOnly).toBeDefined(); }); @@ -203,7 +295,7 @@ describe("buildHostApi — host.storage", () => { ); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); const entry = await host.storage.get("task", "task_1", "note"); expect(entry).toEqual({ key: "note", value: "hi", updatedAt: TEST_UPDATED_AT }); @@ -216,7 +308,7 @@ describe("buildHostApi — host.storage", () => { .fn() .mockResolvedValue(new Response(null, { status: 404 })) as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); expect(await host.storage.get("task", "task_1", "note")).toBeUndefined(); }); @@ -225,7 +317,7 @@ describe("buildHostApi — host.storage", () => { .fn() .mockResolvedValue(new Response(null, { status: 500 })) as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); await expect(host.storage.get("task", "task_1", "note")).rejects.toThrow(); }); }); @@ -246,7 +338,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => { ); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); const result = await host.storage.set("task", "task_1", "note", "hello"); expect(result).toEqual({ updatedAt: TEST_UPDATED_AT }); @@ -267,7 +359,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => { ); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); await host.storage.set("task", "task_1", "note", "a"); await host.storage.set("task", "task_1", "note", "b"); @@ -280,7 +372,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 409 })); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); await expect( host.storage.set("task", "task_1", "note", "hello", { ifUnmodifiedSince: TEST_UPDATED_AT }), ).rejects.toThrow(/modified since/i); @@ -293,7 +385,7 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); await host.storage.delete("task", "task_1", "note"); const [url, init] = fetchMock.mock.calls[0]; @@ -312,13 +404,13 @@ describe("buildHostApi — host.storage set/delete/list/subscribe", () => { new Response(JSON.stringify({ entries }), { status: 200 }), ) as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); expect(await host.storage.list("task", "task_1")).toEqual(entries); }); it("subscribe() returns an unsubscribe function wired through the plugin registry", async () => { const { pluginRegistry } = await import("./registry"); - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); const before = pluginRegistry.getWsHandlers("plugin.user-state.updated").length; const unsubscribe = host.storage.subscribe({}, () => {}); @@ -348,7 +440,7 @@ describe("buildHostApi — host.storage writerId scoping", () => { ); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); await host.storage.set("task", "task_1", "note", "hello", { writerId: "panel-xyz" }); const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); @@ -360,7 +452,7 @@ describe("buildHostApi — host.storage writerId scoping", () => { const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); global.fetch = fetchMock as unknown as typeof fetch; - const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore(), "light"); + const host = buildHostApi(NOTES_PLUGIN_ID, createAppStore()); await host.storage.delete("task", "task_1", "note", { writerId: "panel-xyz" }); const [url] = fetchMock.mock.calls[0]; @@ -399,7 +491,7 @@ describe("buildHostApi — non-secure context (http:// on a non-localhost origin new Response(JSON.stringify({ updatedAt: TEST_UPDATED_AT }), { status: 200 }), ) as unknown as typeof fetch; - const host = freshHostApi.buildHostApi(NOTES_PLUGIN_ID, freshCreateStore(), "light"); + const host = freshHostApi.buildHostApi(NOTES_PLUGIN_ID, freshCreateStore()); await host.storage.set("task", "task_1", "note", { body: "hi" }); const body = JSON.parse( diff --git a/apps/web/lib/plugins/host-api.ts b/apps/web/lib/plugins/host-api.ts index a745166f6d..96eceb6f6d 100644 --- a/apps/web/lib/plugins/host-api.ts +++ b/apps/web/lib/plugins/host-api.ts @@ -64,7 +64,7 @@ import { Switch } from "@kandev/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; import { Textarea } from "@kandev/ui/textarea"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { Combobox } from "@/components/combobox"; import { RichTextEditor, RichTextReadOnly } from "@/components/editors/tiptap/rich-text-editor"; import { PageTopbar } from "@/components/page-topbar"; @@ -74,6 +74,7 @@ import { generateUUID } from "@/lib/utils"; import { softNavigate } from "@/lib/routing/client-router"; import type { AppState } from "@/lib/state/store"; import { pluginModalManager } from "./modal-manager"; +import { readResolvedTheme, subscribeToThemeChanges } from "./theme"; import { composeWriterId, subscribeToUserStateChanges } from "./user-state-sync"; import { PluginStorageConflictError, @@ -159,6 +160,14 @@ const PLUGIN_UI: Record = { Textarea, Tooltip, TooltipContent, + // A plain Tooltip works everywhere a plugin renders, but from two different + // providers: routes and slots render inside AppShell and inherit the + // app-wide TooltipProvider in app/layout.tsx, while `openModal` content + // mounts *outside* AppShell and gets its own from PluginModalHost. This is + // exported for plugins that want their own `delayDuration`/ + // `skipDelayDuration` for a dense cluster of tooltips — nesting a provider + // is supported by Radix. + TooltipProvider, TooltipTrigger, // App UI (not shadcn primitives), exposed so plugins compose kandev-native // surfaces instead of re-implementing them: @@ -212,11 +221,7 @@ function effectiveWriterId(surfaceId: string | undefined): string { return composeWriterId(TAB_WRITER_ID, surfaceId); } -export function buildHostApi( - pluginId: string, - storeApi: StoreApi, - theme: "light" | "dark", -): PluginHostApi { +export function buildHostApi(pluginId: string, storeApi: StoreApi): PluginHostApi { return { pluginId, React, @@ -235,7 +240,14 @@ export function buildHostApi( }, }, ui: PLUGIN_UI, - theme, + // Getter, not a value captured at boot: a plugin built once at page load + // would otherwise read the boot-time theme forever, and one that paints + // imperatively (canvas, inline SVG colors) could never follow a + // light/dark switch without a full reload. + get theme() { + return readResolvedTheme(); + }, + onThemeChange: (listener) => subscribeToThemeChanges(listener), navigate: (href, options) => softNavigate(href, options?.replace ? "replace" : "push"), openModal: (options) => pluginModalManager.openModal(pluginId, options), storage: buildStorageApi(pluginId), diff --git a/apps/web/lib/plugins/host-lifecycle.test.ts b/apps/web/lib/plugins/host-lifecycle.test.ts index cba9644dd2..115c9c41cb 100644 --- a/apps/web/lib/plugins/host-lifecycle.test.ts +++ b/apps/web/lib/plugins/host-lifecycle.test.ts @@ -27,6 +27,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { api: { fetch: async () => new Response(), baseUrl: "" }, ui: {}, theme: "light", + onThemeChange: () => () => {}, navigate: () => {}, openModal: () => ({ close: () => {} }), storage: { diff --git a/apps/web/lib/plugins/host.test.ts b/apps/web/lib/plugins/host.test.ts index b8718719ec..6ca8038824 100644 --- a/apps/web/lib/plugins/host.test.ts +++ b/apps/web/lib/plugins/host.test.ts @@ -34,6 +34,7 @@ function makeHostFactory(pluginId: string): PluginHostApi { api: { fetch: async () => new Response(), baseUrl: "" }, ui: {}, theme: "light", + onThemeChange: () => () => {}, navigate: () => {}, openModal: () => ({ close: () => {} }), storage: { diff --git a/apps/web/lib/plugins/theme.ts b/apps/web/lib/plugins/theme.ts new file mode 100644 index 0000000000..ccc8b26167 --- /dev/null +++ b/apps/web/lib/plugins/theme.ts @@ -0,0 +1,86 @@ +/** + * Resolved light/dark theme for the plugin host (`host.theme` / + * `host.onThemeChange`). + * + * `AppThemeProvider` (components/theme/app-theme.tsx) applies the resolved + * theme by toggling the `light`/`dark` class on ``, and emits no event + * of its own. Observing that class is therefore the only source that covers + * every way the theme can flip — the settings picker, a live preview hover, + * and an OS-level `prefers-color-scheme` change while the app sits on + * "system" — without the plugin host having to subscribe to React context it + * is mounted outside of. + */ + +export type ResolvedTheme = "light" | "dark"; + +/** Reads the resolved light/dark theme currently applied to the document. */ +export function readResolvedTheme(doc: Document = document): ResolvedTheme { + return doc.documentElement.classList.contains("dark") ? "dark" : "light"; +} + +type ThemeListener = (theme: ResolvedTheme) => void; + +const listeners = new Set(); +let observer: MutationObserver | null = null; +let lastTheme: ResolvedTheme | null = null; + +/** + * Fans a class mutation out to subscribers, but only when the *resolved* + * theme actually changed. ``'s class list carries far more than the + * theme (Tailwind state classes, the rendering-engine marker, transition + * suppression), so an unfiltered observer would wake every plugin on + * unrelated churn — expensive for the canvas repaints this exists to drive. + * + * Never throws. These listeners are third-party plugin callbacks and this is + * a cross-plugin fan-out, so one throwing listener must not starve the ones + * after it — the same isolation `dispatchToPluginWsHandlers` and + * `PluginErrorBoundary` already give every other plugin extension point. + * Without it the damage outlives the throw: `lastTheme` has already advanced, + * so the equality guard above means a repeat mutation to the same theme is a + * no-op and the skipped listeners stay stale until some *different* flip + * happens. + */ +function handleMutation(): void { + const next = readResolvedTheme(); + if (next === lastTheme) return; + lastTheme = next; + for (const listener of [...listeners]) { + try { + listener(next); + } catch (error) { + console.error("[plugins] theme change listener threw:", error); + } + } +} + +/** + * Subscribes to resolved-theme changes; returns an unsubscribe function. + * + * One `MutationObserver` is shared across every subscriber and is connected + * lazily on the first subscription / disconnected after the last one, so a + * page with no plugins (or none that care about the theme) observes nothing. + * + * That shared observer and its `listeners`/`lastTheme` state are module + * scope — correct for a browser (one document, one theme), but it means a + * test that subscribes without unsubscribing leaks a live observer into every + * later test in the file. Always call the returned function in cleanup. + */ +export function subscribeToThemeChanges(listener: ThemeListener): () => void { + listeners.add(listener); + if (!observer) { + lastTheme = readResolvedTheme(); + observer = new MutationObserver(handleMutation); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0 && observer) { + observer.disconnect(); + observer = null; + lastTheme = null; + } + }; +} diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 676b5d0abb..b89ff110ea 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -376,7 +376,22 @@ export interface PluginHostApi { * `lib/plugins/host-api.ts` for the full list. */ ui: Record; - theme: "light" | "dark"; + /** + * The resolved light/dark theme, read live on every access — a plugin's + * `host` object is built once, so this must not be captured into a local + * variable that outlives a render if you want it to stay current. + * Components that read it during render re-read it on every re-render; + * anything that paints imperatively (canvas, inline SVG colors) should pair + * it with `onThemeChange`. + */ + readonly theme: "light" | "dark"; + /** + * Subscribes to light/dark changes — the settings picker, its live preview, + * and an OS `prefers-color-scheme` flip while the app is on "system". + * Returns an unsubscribe function; call it on teardown (component unmount, + * `KandevPlugin.destroy`) or the listener outlives the surface that owns it. + */ + onThemeChange(listener: (theme: "light" | "dark") => void): () => void; /** Soft SPA navigation (history push/replace + re-render), same as the app's router. */ navigate(href: string, options?: { replace?: boolean }): void; /** diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 56f3fe7e5a..91dab4f60c 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -57,7 +57,16 @@ interface PluginHostApi { baseUrl: string; }; ui: Record; // curated @kandev/ui components + app UI (see below) - theme: "light" | "dark"; + // The resolved light/dark theme, read live on every access. `host` is built + // once per plugin load, so copying this into a variable that outlives a + // render freezes it; read it during render, and pair it with onThemeChange + // for anything that paints imperatively (canvas, inline SVG colors). + readonly theme: "light" | "dark"; + // Fires on every light/dark change — the settings picker, its live preview, + // and an OS prefers-color-scheme flip while the app is set to "system". + // Returns an unsubscribe function; call it on teardown (component unmount, + // KandevPlugin.destroy) or the listener outlives the surface that owns it. + onThemeChange(listener: (theme: "light" | "dark") => void): () => void; // Soft SPA navigation (history push/replace + SPA re-render) — same code // path as the app router, so plugin pages can link into native routes // (e.g. /t/{taskId}) without a full reload. @@ -156,7 +165,7 @@ interface PluginModalHandle { `host.ui` contents: shadcn primitives (Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, -Tooltip*) plus first-party app UI: `PageTopbar` (the kandev title bar, for +Tooltip*, including `TooltipProvider`) 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 @@ -169,6 +178,12 @@ Radix/portal/context-based would split React context across instances and break refs/`asChild`. Pure-React libs (e.g. `@tabler/icons-react`) bundle fine. +The host wraps plugin routes, slots, and `openModal` content in a +`TooltipProvider`, so a plain `Tooltip` works anywhere without one. +`TooltipProvider` is exported for plugins that want their own +`delayDuration`/`skipDelayDuration` over a dense cluster of tooltips; Radix +supports nesting it. + ### `host.ui.RichTextEditor` / `host.ui.RichTextReadOnly` Pixel-identical to the Plan panel's markdown editor (paste handling, slash diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index e006b7d1b9..d76222d786 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -101,7 +101,7 @@ ui: # optional native frontend plugin | `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. | | `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. | | `runtime.executables` | required when `runtime.type: binary` | map\ | Key is `-` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. | -| `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. | +| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= , running ` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. | | `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | | `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. | | `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |