6393d2df85
Co-authored-by: Kandev Agent <agent@kandev.dev>
702 lines
37 KiB
Markdown
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.
|