fix(plugins): repair modal tooltips, live theme, and min_kandev_version (#2408)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-08 02:12:44 +02:00
committed by GitHub
parent bf62f39b10
commit fffde518dd
19 changed files with 582 additions and 69 deletions
@@ -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 <html> 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({
@@ -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
}
@@ -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)
+25 -12
View File
@@ -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 {
@@ -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
@@ -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 `<AppShell/>` (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 (
<Tooltip>
<TooltipTrigger data-testid="tooltip-trigger">Trigger</TooltipTrigger>
<TooltipContent>Helpful hint</TooltipContent>
</Tooltip>
);
}
it("renders modal content containing a Tooltip without throwing", () => {
pluginModalManager.openModal("plugin-a", {
title: "Tooltip Modal",
content: TooltipContentComponent,
});
render(<PluginModalHost />);
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(<PluginModalHost />);
fireEvent.focus(screen.getByTestId("tooltip-trigger"));
await waitFor(() => {
expect(screen.getAllByText("Helpful hint").length).toBeGreaterThan(0);
});
});
});
@@ -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 `<PluginBootBridge/>`.
*
* The `TooltipProvider` is not redundant with the app-wide one in
* `app/layout.tsx`: this host mounts as a *sibling* of `<AppShell/>` (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 (
<>
<TooltipProvider>
{modals.map((modal) => (
<PluginModalInstance key={modal.instanceId} modal={modal} />
))}
</>
</TooltipProvider>
);
}
@@ -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<AppState>,
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<string | null>(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<string | null>(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 {
+75 -1
View File
@@ -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 `<AppShell>` (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 <html>'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,
+1 -7
View File
@@ -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<StoreApi<AppState>>();
/** 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));
}
@@ -28,6 +28,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
storage: {
+118 -26
View File
@@ -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<void> {
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 <html> — 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(
+19 -7
View File
@@ -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<string, unknown> = {
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<AppState>,
theme: "light" | "dark",
): PluginHostApi {
export function buildHostApi(pluginId: string, storeApi: StoreApi<AppState>): 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),
@@ -27,6 +27,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
storage: {
+1
View File
@@ -34,6 +34,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
api: { fetch: async () => new Response(), baseUrl: "" },
ui: {},
theme: "light",
onThemeChange: () => () => {},
navigate: () => {},
openModal: () => ({ close: () => {} }),
storage: {
+86
View File
@@ -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 `<html>`, 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<ThemeListener>();
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. `<html>`'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;
}
};
}
+16 -1
View File
@@ -376,7 +376,22 @@ export interface PluginHostApi {
* `lib/plugins/host-api.ts` for the full list.
*/
ui: Record<string, unknown>;
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;
/**
+17 -2
View File
@@ -57,7 +57,16 @@ interface PluginHostApi {
baseUrl: string;
};
ui: Record<string, unknown>; // 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
+1 -1
View File
@@ -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\<string,string\> | Key is `<goos>-<goarch>` (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 >= <version>, running <version>` 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. |