Files
kandev/docs/plans/plugins/PLUGIN-API.md
T
2026-08-08 07:58:17 +02:00

702 lines
37 KiB
Markdown

# Kandev Plugin API contract (native JS UI plugins — "option C")
This is the frozen interface every frontend + example task builds against. Do not
diverge without updating this file.
## Loading model
1. Backend boot payload gains `plugins: ActivePlugin[]` where
`ActivePlugin = { id: string; name: string; bundleUrl: string; styleUrls?: string[] }`.
`bundleUrl` = `/api/plugins/{id}/bundle` — kandev serves this **directly from the
extracted package directory** on local disk
(`~/.kandev/plugins/<id>/<version>/ui/...`, per manifest `ui.bundle`). There is no
reverse proxy and no live upstream request: the plugin subprocess does not need to
be running to serve the UI bundle, since installation already extracted the file.
2. On SPA boot, the **plugin host** (`apps/web/lib/plugins/host.ts`) iterates
`bootPayload.plugins`, injects any `styleUrls` as `<link>`, and dynamically
`import(/* @vite-ignore */ bundleUrl)` each bundle as a native ES module.
3. Each bundle, when evaluated, calls the global:
```ts
window.registerKandevPlugin(pluginId, {
initialize(registry, host): void | Promise<void>,
destroy?(): void,
})
```
4. After the module resolves, the host calls `initialize(registry, host)`. A
reload/update may unregister the previous generation before starting the next
one; the host keeps that transition unresolved until the current generation's
initialization finishes. Slow or failed reloads do not by themselves revoke
open or saved task panels. On explicit plugin disable/uninstall the host calls
`destroy?.()`, removes the plugin's registrations, and closes its panels.
## Global entry point
`window.registerKandevPlugin(id: string, plugin: KandevPlugin)` — defined by the
host before any bundle loads. Bundles are authored with React as an **external**;
they must use `host.React` (NOT bundle their own React) to share the host instance.
## `host: PluginHostApi`
```ts
interface PluginHostApi {
pluginId: string;
React: typeof import("react"); // host React instance (shared)
jsx: typeof React.createElement; // convenience alias (h)
store: { // kandev app store (zustand StoreApi)
getState(): AppState;
setState(partial): void;
subscribe(listener): () => void;
};
api: {
// fetch scoped to this plugin's backend via kandev proxy:
// GET/POST {method} /api/plugins/{id}/... ; returns parsed JSON
fetch(path: string, init?: RequestInit): Promise<Response>;
// Backend API origin ("" when SPA and API share an origin) — for reaching
// first-party kandev REST endpoints without re-deriving the split-origin
// dev/desktop base URL from window internals.
baseUrl: string;
};
ui: Record<string, unknown>; // curated @kandev/ui components + app UI (see below)
// 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.
navigate(href: string, options?: { replace?: boolean }): void;
// Imperatively opens a modal window rendered by the host's <PluginModalHost/>
// (mounted once at the app root, isolated behind its own error boundary).
// Independent of keybindings — any plugin code path may call it.
openModal(options: PluginModalOptions): PluginModalHandle;
// Sonner's imperative toast. The host mounts the single <Toaster/>, so
// there is nothing to render and nothing to wire — and because it is
// imperative rather than a component, it works from inside a plugin modal
// regardless of which providers that modal sits under.
toast: PluginToastApi;
// Shared helpers — plain functions, so they live here rather than in `ui`
// (a component map). See the "host.utils" section below.
utils: PluginUtilsApi;
// Authenticated, per-user key/value storage backed by
// /api/plugins/{id}/user-state/... — see the "host.storage" section below.
// Requires the plugin manifest to declare capabilities.user_state: true.
storage: PluginStorageApi;
}
interface PluginStorageEntry { key: string; value: unknown; updatedAt: string; }
interface PluginStorageSetOptions {
// Optimistic-concurrency guard: the updatedAt the caller last read. The
// write is rejected (the returned promise rejects with a
// PluginStorageConflictError) if the stored row was modified after this
// time, leaving the stored value unchanged. Omit for unconditional
// last-write-wins (the default).
ifUnmodifiedSince?: string;
// Identifies which logical surface made this write, for echo suppression
// (see subscribe's writerId filter below). Appended to the host's own
// per-tab id — not a replacement for it — so a static surface id like a
// dockview panelId (shared by every tab that has that panel open) can't
// make two different tabs look like the same writer to each other. Omit
// to use the shared per-tab default alone — fine for a one-shot write with
// no ongoing subscription (e.g. a kanban menu action). A surface that also
// subscribes to its own writes (e.g. a task panel) should pass something
// stable and unique to that surface, such as its own panelId from
// PluginTaskPanelProps — otherwise its writes are indistinguishable from
// any other surface of the same plugin in this tab, and one surface's
// legitimate write can be silently swallowed by another surface's
// subscription as if it were that other surface's own echo.
writerId?: string;
}
// Mirrors the backend's user-state route scopes.
type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository";
interface PluginStorageApi {
get(scope: PluginStorageScope, scopeId: string, key: string): Promise<PluginStorageEntry | undefined>;
set(
scope: PluginStorageScope, scopeId: string, key: string, value: unknown,
options?: PluginStorageSetOptions,
): Promise<{ updatedAt: string }>;
delete(
scope: PluginStorageScope, scopeId: string, key: string,
options?: Pick<PluginStorageSetOptions, "writerId">,
): Promise<void>;
// Every entry under (scope, scopeId), ordered by key. Not paginated.
list(scope: PluginStorageScope, scopeId: string): Promise<PluginStorageEntry[]>;
// Subscribes to live updates for this plugin's own storage made from
// another tab, device, or surface — e.g. the kanban Edit modal and the
// task panel both editing the same document. filter.scope/scopeId/key
// narrow to a specific tuple; omit a field to match any value.
//
// filter.writerId, if given, must be the same value this surface passes to
// set/delete's own writerId option — the host combines it with its own
// per-tab id the same way on both sides, so a notification carrying that
// resulting combined id is this surface's own echo and is skipped, and its
// editor never clobbers its own caret/selection reacting to its own write.
// Omit to fall back to the shared per-tab default alone: correct for a
// plugin with only one surface, but two independent surfaces of the same
// plugin (e.g. an open task panel and a kanban quick-action) both omitting
// it would incorrectly suppress each other's legitimate writes as if they
// were one surface's own echo.
subscribe(
filter: { scope?: PluginStorageScope; scopeId?: string; key?: string; writerId?: string },
handler: (change: PluginUserStateChange) => void,
): () => void;
}
interface PluginUserStateChange {
scope: PluginStorageScope;
scopeId: string;
key: string;
updatedAt: string;
deleted?: boolean; // true when the change was a delete rather than a set
}
interface PluginModalOptions {
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
content: React.ComponentType<{ slotProps?: unknown }>; // reuses the slot-component contract
size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md"
dismissible?: boolean; // overlay click / Escape close the modal; default true
}
interface PluginModalHandle {
close(): void; // closes this modal instance; no-op if already closed
}
```
`host.ui` contents: shadcn primitives (Accordion*, Alert*, Badge, Button,
Card*, Checkbox, Collapsible*, Dialog*, DropdownMenu*, Empty*, Input, Kbd,
KbdGroup, Label, Pagination*, Popover*, Progress, ScrollArea, Select*,
Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea,
Tooltip*, including `TooltipProvider`), the recharts wrappers (`ChartContainer`,
`ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`,
`ChartStyle`), plus first-party app UI: `PageTopbar` (the kandev title bar, for
routes that opt out of the default chrome and own their layout),
`TaskCreateDialog` (kandev's real create-task modal, prefilled via
`initialValues`), `Combobox` (the app's Command+Popover picker), and
`RichTextEditor`/`RichTextReadOnly` (narrow wrappers over the Plan panel's
tiptap markdown editor — see below). The authoritative list is
`apps/web/lib/plugins/host-api.ts` (`PLUGIN_UI`).
Plugins must use these host instances — bundling copies of anything
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.
**Charts use the host's recharts.** `recharts` is already a dependency of both
`apps/web` and `@kandev/ui`, so the `Chart*` exports add no bundle weight — and
a plugin must never bundle its own copy. recharts drives layout through its own
React context and portals its tooltips, so a second copy splits that context
exactly the way a second Radix copy does: charts render at zero size, tooltips
attach to the wrong tree, and responsive containers stop resizing. Compose the
`Chart*` wrappers with the chart primitives they hand you rather than importing
`recharts` in plugin code.
### `host.toast` and `host.utils`
```ts
// Sonner's imperative toast — the host owns the single <Toaster/>, so there
// is nothing to render and it works from any plugin code path, modals
// included.
host.toast.success("Synced 12 issues");
host.toast.error("Sync failed");
interface PluginToastApi {
(message: string, options?: Record<string, unknown>): string | number;
success(message: string, options?: Record<string, unknown>): string | number;
// Renders the same toast as any other variant, and additionally logs
// `[plugins] toast.error from "<pluginId>":` to the browser console.
// It does NOT file a report into kandev's frontend error log — that log is
// for kandev's own application errors, and a plugin toasting an expected
// condition (a failed poll, say) would otherwise record an Error-level
// entry every cycle. Console is where every plugin failure surfaces.
error(message: string, options?: Record<string, unknown>): string | number;
warning(message: string, options?: Record<string, unknown>): string | number;
info(message: string, options?: Record<string, unknown>): string | number;
// Dismisses one toast by the id a variant returned, or all of them when
// called with no argument.
dismiss(id?: string | number): unknown;
}
interface PluginUtilsApi {
// The host's clsx + tailwind-merge combiner, so class merging matches the
// components it styles.
cn(...inputs: unknown[]): string;
// Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
// Intl.RelativeTimeFormat in the user's active locale; "" for unparseable
// input. Prefer it over a hand-rolled ladder, which is English-only by
// construction and goes untranslated for every non-English user.
formatRelativeTime(value: string | number | Date): string;
}
```
Both are functions, so they sit beside `navigate`/`openModal` rather than in
`ui`, which is a component map.
### `host.ui.RichTextEditor` / `host.ui.RichTextReadOnly`
Pixel-identical to the Plan panel's markdown editor (paste handling, slash
commands, drag handles, mermaid), so a plugin doesn't ship its own tiptap:
```ts
// RichTextEditor: editable, value/onChange round-trip markdown.
interface RichTextEditorProps {
taskId: string; // required — scopes mermaid/image asset resolution
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
testId?: string;
}
// RichTextReadOnly: renders markdown read-only, no taskId dependency.
interface RichTextReadOnlyProps { value: string; className?: string; testId?: string; }
```
Deliberately narrow — not the plan editor's `comments`, `onSelectionChange`,
`onCommentClick`, `onCommentDeleted`, or `onEditorReady` props, so the plan
editor's internals can keep evolving without breaking this contract.
### `host.storage` — authenticated per-user key/value storage
Backed by `PUT/GET/DELETE /api/plugins/{id}/user-state/{scope}/{scopeId}/{key}`
and `GET /api/plugins/{id}/user-state/{scope}/{scopeId}` (list). Every
read/write is scoped to the calling user via the session/PAT identity — two
users writing the same `(scope, scopeId, key)` never see each other's value;
a `GET` for another user's key returns `404`. Requires the plugin manifest to
declare `capabilities.user_state: true` (`403` otherwise); an unknown or
disabled plugin returns `404`. `scopeId`/`key` must match
`[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`; the request body is capped (`413` over
the limit — see `apps/backend/internal/plugins/user_state_handlers.go`'s
`maxUserStateBodyBytes`). `PUT` accepts an optional `ifUnmodifiedSince`
(compared against the stored row's `updatedAt`) — a conflicting write returns
`409` and leaves the stored value unchanged.
This is entirely separate from the plugin-owned `plugin_state` table (written
only by a plugin's own gRPC-connected backend via the Host `SetState` RPC) —
`host.storage` needs no plugin backend at all, so a UI-only plugin bundle can
persist data with zero Go code.
`host.storage.set` stamps a per-browser-tab `writerId` on every write (one id
per page load, shared across every plugin in that tab). A successful
`PUT`/`DELETE` publishes the `plugin.user-state.updated` WS action to the
writing user's own connections only:
```ts
// WS message: { action: "plugin.user-state.updated", payload: PluginUserStateUpdatedPayload }
interface PluginUserStateUpdatedPayload {
pluginId: string;
scope: PluginStorageScope;
scopeId: string;
key: string;
updatedAt: string;
writerId?: string;
deleted?: boolean;
}
```
The payload carries keys only, never the stored value — a subscriber refetches
via `host.storage.get`. `host.storage.subscribe(...)` is a typed convenience
wrapper over `registry.registerWsHandler("plugin.user-state.updated", ...)`
that already filters to this plugin's own events, applies your `scope`/
`scopeId`/`key` filter, and skips notifications whose `writerId` matches this
tab's own writes (so an editor never clobbers its own caret/selection from its
own write).
## `registry: PluginRegistry`
```ts
// icon: curated icon name (apps/web/lib/plugins/icons.ts — "ticket", "chart",
// "robot", "database", ...); unknown/missing names render a puzzle glyph in
// the sidebar.
// section: "main" (default) renders as a top-level sidebar entry;
// "integrations" renders inside the sidebar's Integrations section alongside
// the first-party integration links (GitHub, Jira, ...). Hosts predating a
// section value simply don't render items targeting it (additive change).
interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; }
// Configuration for the kandev-style title bar the host renders above a plugin
// route. All fields optional; defaults are derived (see registerRoute below).
interface PluginPageChrome {
title?: string; // default: nav-item label for the same path, else plugin name
subtitle?: string; // muted text next to the title
icon?: string; // curated icon name; default: matching nav item's icon
backHref?: string; // back-link target (host default "/")
backLabel?: string; // back-link label (host default "Kandev")
actions?: React.ComponentType; // rendered on the right side of the topbar
}
interface PluginRouteOptions {
// Default: enabled with derived title. Object → configure; false → render the
// route full-bleed and own the chrome (e.g. with host.ui.PageTopbar).
topbar?: boolean | PluginPageChrome;
}
interface PluginRegistry {
// Top-level SPA route, e.g. "/jira". Component rendered by the SPA route resolver
// when window.location path === path (exact match; trailing segments via ":param" not
// required for v1 — exact + startsWith("/plugins/{id}") allowed). The host wraps the
// page in kandev chrome (PageTopbar + scrollable content area) by default —
// configure or opt out via options.topbar.
registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void;
// Sidebar/main nav entry. Rendered by <PluginNavItems/> in the app sidebar,
// and by <MobilePluginNavSection/> in the phone menu sheet (the sidebar is
// hidden below md), with item.icon resolved against the curated icon map
// (fallback: puzzle).
registerNavItem(item: NavItem): void;
// Route under /settings/plugins/{id}/... rendered inside settings shell.
// The settings shell already provides its own topbar chrome — no options here.
registerSettingsRoute(path: string, Component: React.ComponentType): void;
// Named slot injection. Host renders all components registered for a slot via
// <PluginSlot name="..." slotProps={...}/>. Initial slots: "task-sidebar",
// "settings-nav", "chat-input-actions", "chat-top-bar",
// "main-top-bar", "app-status-bar-left", "app-status-bar-right",
// "plugin-settings", "task-card-indicators", and "task-card-tags".
// "task-card-indicators" renders a small icon/badge beside the PR status
// icon on every kanban card and forwards
// `{ taskId, workspaceId, workflowStepId }` as `slotProps`. Not a closed
// union — hosts may register additional slot names.
// "task-card-tags" renders in its own row on every kanban card, below the
// badges row — for contributions too wide for the cramped title-row
// "task-card-indicators" spot (e.g. a row of tag chips) — and forwards the
// same `{ taskId: string, workspaceId: string | null, workflowStepId: string | null }`
// shape as `slotProps` (`workspaceId` is null with no active workspace, and
// `workflowStepId` is null when the task has no workflow step assigned).
// "chat-input-actions" renders icon buttons in the chat composer toolbar
// (beside the model picker, mic, and send) and forwards
// `{ taskId, taskTitle, activeSessionId, sessionIds }` as `slotProps`.
// "chat-top-bar" renders status in the session top bar (beside the
// document/editor/debug controls) and forwards
// `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }`. Both
// carry the active session plus every kandev session id on the task.
// "main-top-bar" renders status/actions in the default app top bar on the
// Home / Kanban / Tasks views (beside the CPU/DB metrics and the view/display
// controls) and forwards `{ workspaceId, workspaceLabel, currentPage }`. It is
// the app-wide, task-agnostic counterpart to "chat-top-bar", so it carries no
// task/session ids.
// Resolving a session id to an agent/ACP transcript id (e.g. to key
// tokscale cost data on a session) is the plugin's job, done server-side in
// the plugin backend via the Host data API; the host only propagates ids.
// "plugin-settings" renders inline on a plugin's own settings page
// (Settings > Plugins > <plugin>, at the top above the schema-driven settings
// form) and forwards `{ pluginId: string, status: PluginStatus }`
// as `slotProps`. It is owner-scoped: the host renders only the component
// registered by the plugin currently being viewed, so your card appears on
// your own settings page and never on another plugin's — no per-id gating
// needed in your component.
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
// WS action handler. Bridged into the existing lib/ws dispatch; called with the
// decoded message payload for that action string.
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
// Binds a handler to a keybinding declared in this plugin's manifest
// (ui.keybindings[].id — { id, default, description }, see manifest schema).
// The host resolves the effective combo (user override if the user
// rebound it, else the manifest default) and dispatches globally, skipping
// editable targets the same way core app shortcuts do. Combos are
// user-overridable in Settings > Keyboard Shortcuts, namespaced
// `plugin:{pluginId}:{id}`. Binding an id the manifest didn't declare still
// stores the handler (a console warning is logged) since the dispatcher's
// effective-shortcut resolution keys off the manifest list.
//
// Combo grammar (manifest `default` and any user override): `+`-separated
// tokens, one of the modifiers `mod|ctrl|cmd|meta|alt|option|shift`
// (repeatable) plus exactly one key token. `mod` resolves to Cmd on macOS
// and Ctrl elsewhere (⌘/Ctrl). `shift` may not be combined with a digit or
// symbol key (e.g. `shift+1`, `shift+slash`) — Shift changes the character
// a browser reports for those keys, so the combo could never dispatch; both
// the manifest validator and the frontend parser reject it.
registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void;
// Contributes a panel to the task workspace "+" (add panel) menu (dockview
// desktop) and, when mobileEnabled, the phone bottom nav. Panel identity
// lives in the dockview params (pluginId/panelKey); the panel id is
// `plugin:{pluginId}:{panelKey}`. See "Task panels" below.
registerTaskPanel(registration: TaskPanelRegistration): void;
// Contributes an item to the kanban card's Edit submenu (group "edit") or
// a flat, top-level card menu item between "Move to"/"Send to workflow"
// and "Link" (group "primary"). See "Kanban card contributions" below.
registerTaskMenuAction(registration: TaskMenuActionRegistration): void;
// Contributes a client-side filter section to the kanban board's display
// dropdown, alongside the built-in Workflow/Repository sections. See
// "Task filters" below.
registerTaskFilter(registration: TaskFilterRegistration): void;
}
type PluginPresentation = "desktop" | "mobile";
interface PluginTaskPanelProps {
panelId: string; // this registration's panel id, so one Component can back multiple panels
taskId: string;
sessionId: string | null;
presentation: PluginPresentation;
}
interface TaskPanelRegistration {
id: string; // plugin-local panel id (unique within the plugin, not globally)
title: string; // add-panel-menu row label and dockview tab title
icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts)
Component: React.ComponentType<PluginTaskPanelProps>; // wrapped in a PluginErrorBoundary
mobileEnabled?: boolean; // include in the phone's grouped Panels picker. Default: false.
}
interface PluginTaskMenuContext {
workspaceId: string;
taskId: string;
taskTitle: string;
workflowStepId: string | null;
presentation: PluginPresentation; // the actual kanban layout: desktop or mobile
}
interface TaskMenuActionRegistration {
id: string;
label: string;
icon?: React.ReactNode;
// "edit" nests the item in the card's Edit submenu; "primary" renders it
// as a flat, top-level item between the "Move to"/"Send to workflow"
// submenus and the "Link" submenu.
group: "edit" | "primary";
visible?(context: PluginTaskMenuContext): boolean; // default: always visible
run(context: PluginTaskMenuContext): void | Promise<void>; // a rejection is caught and logged
}
interface PluginTaskFilterContext {
taskId: string;
}
interface PluginTaskFilterOption {
value: string;
label: string;
color?: string; // optional swatch color rendered beside the option label
}
interface TaskFilterRegistration {
id: string; // plugin-local filter id (unique within the plugin, not globally)
label: string; // filter section label shown in the dropdown
getOptions(): PluginTaskFilterOption[];
// Called only when `selected` is non-empty — an empty selection is
// implicit "All" and always matches without invoking this method.
matches(context: PluginTaskFilterContext, selected: string[]): boolean;
}
```
### App-status-bar slots
`app-status-bar-left` and `app-status-bar-right` are live named component slots.
Each registration is one opaque status item; the slot chooses its default side,
not a permanent side after user customization. Components receive
`slotProps` with this exact shape:
```ts
interface AppStatusBarSlotProps {
placement: "left" | "right";
presentation: "bar" | "mobile-drawer";
density: "full" | "compact";
pathname: string;
activeWorkspaceId: string | null;
activeTaskId: string | null;
activeSessionId: string | null;
}
```
`placement` matches registration slot. `presentation` identifies the mounted host;
the host mounts only one presentation at once. `density` is `full` on desktop and
phone drawer, `compact` on tablet. `pathname` and active IDs are current-context
hints, not entity payloads; read complete records from `host.store`.
Before customization, registration order is render order within each default side.
Users can Cmd-drag on macOS or Ctrl-drag elsewhere with a mouse to move any item
across the whole desktop/tablet bar. Kandev stores that order in backend user
settings; disabled contributions keep their place and return there when enabled.
Phone renders the saved left sequence followed by the saved right sequence, without
dragging. There is no cross-plugin priority API, keyboard-arrow ordering, or touch
ordering. Enable, disable, and uninstall update slots without reload. Each component
is isolated by an owner-aware error boundary, so plugins must tolerate remounting and
render a compact bar control or touch-usable drawer row for the supplied presentation.
The host neither inspects nor separately reorders children inside a registration, and
does not add a nested interactive wrapper.
A full-bleed plugin route (`topbar: false`) opts out of host chrome. It may mount
the host-provided Status drawer trigger when its own chrome should expose status;
otherwise status access is intentionally its responsibility.
### Task panels
`registerTaskPanel` adds one row to the task workspace's "+" (add panel)
menu, after "Plan". Selecting it opens a dockview panel using a single
generic `"plugin-panel"` dockview component shared by every plugin panel —
panel identity lives in `params: { pluginId, panelKey }`, and the panel id is
`plugin:{pluginId}:{panelKey}`. This keeps the host's panel-rendering dispatch
to one branch per host release rather than one per plugin, and lets a saved
layout round-trip a plugin panel reference even when that plugin is no
longer installed: the layout manager drops (not throws on) an unresolvable
plugin panel, and `Settings > Layouts` renders a generic placeholder box for
one it can't render live.
Disabling or uninstalling a plugin closes any of its open panels in the
current session and removes its add-panel-menu row; a panel your Component
was actively rendering unmounts in place (no console error, no dockview
exception). A plugin `Component` that throws during render shows a small
"failed to load" fallback inside just that panel — the panel error boundary
is scoped to your panel only, not the surrounding dockview layout.
On a phone viewport, `mobileEnabled: true` adds the panel to one grouped
**Panels** bottom-nav action (after Terminal); it does not add one navigation item
per panel. The touch-sized `MobilePickerSheet` presents every available panel in
an internally scrolling list. Selecting a row dismisses the picker and renders
your `Component` as the single full-height mobile surface with
`presentation: "mobile"` — the same `Component`, no separate mobile
registration. During a slow or failed reload, the host preserves a selected panel;
after a ready generation, a panel omitted by the new registration is closed. An
explicit disable or uninstall closes every panel owned by the plugin.
### Kanban card contributions
With no plugin registered for group `"edit"`, the kanban card's context/
dropdown menu shows the same flat `Edit` item as today. Once any plugin
registers a `registerTaskMenuAction({ group: "edit", ... })`, that item
becomes an `Edit` submenu: `Edit task` (the original action) first, then each
visible plugin action in registration order. An action whose `visible(context)`
returns `false` is filtered out entirely (not shown disabled). Selecting an
action calls `run(context)`; a rejected promise is caught and logged to the
console, and the menu still closes either way (Radix's own close-on-select,
independent of the async result).
Group `"primary"` renders each visible action as its own flat, top-level menu
item instead of nesting it under `Edit` — positioned between the "Move
to"/"Send to workflow" submenus and the "Link" submenu. Visibility filtering,
registration order, and `run()`/error handling are identical to the `"edit"`
group; the two groups are independent lists (an action only ever belongs to
one).
`"task-card-indicators"` (documented above with the other slots) is the
matching read-only surface: a small icon/badge rendered beside the PR status
icon on every card, receiving `{ taskId, workspaceId, workflowStepId }`.
`"task-card-tags"` is a second, sibling read-only surface for the same
context — same `{ taskId, workspaceId, workflowStepId }` shape — but mounted
in its own row on the card instead of the cramped title row
`"task-card-indicators"` shares with `PRTaskIcon`. Use it for a contribution
that needs its own width, e.g. a row of tag chips.
### Task filters
`registerTaskFilter` adds one section to the kanban board's display dropdown
(the same menu that holds the built-in Workflow and Repository filters),
rendered below Repository and above the Preview Panel section. Each
registration's `getOptions()` supplies the section's checkbox list; the
plugin owns option identity, ordering, and labels — including any
"untagged"-style sentinel, which is just a normal option value the host does
not special-case.
Selections are multi-select and purely client-side against tasks already
loaded in the board's in-memory state: there is no backend query, pagination,
or persistence for this filter, and selections reset on reload (unlike
Workflow/Repository, which persist to backend user settings). An empty
selection is implicit "All" for that section — `matches()` is only invoked
once at least one option is selected, and multiple plugin filter sections
combine with AND (a task must match every section with an active selection),
mirroring how Workflow/Repository combine with the search query today. If
`matches()` throws, the task is treated as non-matching and the error is
logged (mirroring `TaskMenuActionRegistration.visible`'s error handling).
## Registry internals (host side)
`apps/web/lib/plugins/registry.ts` holds a singleton `PluginRegistry` whose data
is reactive (a small zustand store or event emitter) so host React components
re-render when registrations change. Every registration records the owning
`pluginId` so the host can bulk-unregister on disable. Exposes read selectors:
`getRoutes()` (each entry carries `pluginId` + `options`), `getNavItems()`,
`getSettingsRoutes()`, `getSlotComponents(slot)`, `getWsHandlers(action)`,
`getPluginName(pluginId)` (display name recorded by `forPlugin(id, name)`, used
for derived page-chrome titles), `getTaskPanels()` / `getTaskPanel(pluginId, id)`,
and `getTaskMenuActions(group?)`. `unregisterWsHandler(pluginId, action, handler)`
removes exactly one WS handler (used by `host.storage.subscribe`'s returned
unsubscribe) without disturbing the plugin's other registrations.
Plugin top-level routes render inside `PluginPageFrame`
(`apps/web/components/plugins/plugin-page.tsx`): a `PageTopbar` title bar above
a scrollable content area, resolved from `options.topbar` with derived
defaults, or the bare component when the route opted out (`topbar: false`).
## Integration points the app must add (task-19)
- `src/spa-routes.tsx`: after the static route switch, before the not-found
fallback, consult `registry.getRoutes()` for a matching path and render it inside
the normal app shell.
- `src/settings-routes.tsx`: consult `registry.getSettingsRoutes()` for
`/settings/plugins/{id}/*` paths.
- App sidebar (grep the nav list component): render `<PluginNavItems/>` reading
`registry.getNavItems()`.
- `lib/ws/router.ts` / `lib/ws/client.ts`: after built-in dispatch, forward the
message to any `registry.getWsHandlers(action)`.
- `components/plugins/plugin-slot.tsx`: `<PluginSlot name props/>` renders all
slot components; drop into task detail sidebar + settings nav as initial hosts.
The chat composer toolbar
(`components/task/chat/chat-input-toolbar-desktop.tsx` and
`-mobile.tsx`, via `chat-input-plugin-actions.tsx`) hosts the
`chat-input-actions` slot, passing
`{ taskId, taskTitle, activeSessionId, sessionIds }`. `kanban-card-plugin-slots.tsx`
hosts `task-card-indicators` beside `PRTaskIcon` and `task-card-tags` as its
own row, both mounted from `kanban-card-content.tsx`'s `KanbanCardBody`.
- `components/task/dockview-shared.tsx` / `dockview-panel-content.tsx` /
`dockview-desktop-layout.tsx`: the generic `"plugin-panel"` dockview
component + `pluginPanelTab`, and `renderPanel`'s `"plugin-panel"` case
resolving `{ pluginId, panelKey }` via `registry.getTaskPanel(...)`.
`dockview-add-panel-items.tsx` renders one "+" menu row per
`registry.getTaskPanels()`. `use-close-revoked-plugin-panels.ts` closes an
open panel whose registration disappeared.
- `components/task/mobile/session-mobile-bottom-nav.tsx` /
`plugin-panel-picker.tsx` / `session-mobile-layout.tsx`: expose all
`mobileEnabled` registrations through one grouped Panels picker, and reconcile
the focused panel against host lifecycle state; `MobilePanelArea` renders the
selected `plugin:{pluginId}:{panelKey}` panel.
- `components/kanban-card-edit-submenu.tsx`: builds the card's `Edit` entry
from `registry.getTaskMenuActions("edit")`.
- `lib/state/layout-manager/plugin-panels.ts`: `pluginPanelId`/
`parsePluginPanelId` identity helpers plus registry-aware
`isKnownPanelId`/`isStructuralComponent`/`resolvePluginPanelDefinition`,
consulted by the layout manager's persistence/merge logic.
- `lib/plugins/host-api.ts` / `user-state-sync.ts`: `host.storage`
implementation (fetch against the user-state routes, per-tab `writerId`)
and `host.storage.subscribe` (over `registerWsHandler`).
- `internal/gateway/websocket/user_notifications.go` (backend): subscribes
the `plugin.user-state.updated` bus event into the existing
`UserEventBroadcaster` (user-scoped fan-out, same path as
`user.settings.updated`).
## Security posture (documented, enforced where cheap)
Plugin JS runs in the kandev origin with store access — this is the accepted
tradeoff of option C. v1 mitigations: only **active, operator-installed** plugins
load; bundles are served by kandev from the extracted package directory (same-origin,
no third-party CDN, no upstream network hop); host wraps `initialize` in try/catch so
a broken plugin can't crash boot; registrations namespaced + bulk-revocable per
plugin. No credentials are ever displayed to the operator — installing a plugin (via
URL or upload) has nothing to copy or reveal, unlike the old register flow's one-time
API key/webhook secret. Sandboxing plugin JS (worker/realms) is explicit future work.
## Example plugin must (task-21)
Ship a bundle that on `initialize` registers: a nav item "Hello" → route
`/plugins/hello` rendering a native page (uses `host.jsx` + `host.ui`), a
`task-sidebar` slot component, and a WS handler for `task.created` that updates a
counter in its page via the plugin's own module state. No bundled React.