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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeHostFactory(pluginId: string): PluginHostApi {
|
||||
api: { fetch: async () => new Response(), baseUrl: "" },
|
||||
ui: {},
|
||||
theme: "light",
|
||||
onThemeChange: () => () => {},
|
||||
navigate: () => {},
|
||||
openModal: () => ({ close: () => {} }),
|
||||
storage: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user