feat(plugins): add task panel, menu, and storage hooks (#2152)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -22,9 +22,12 @@ diverge without updating this file.
|
||||
destroy?(): void,
|
||||
})
|
||||
```
|
||||
4. After the module resolves, the host calls `initialize(registry, host)`. On
|
||||
plugin disable/uninstall the host calls `destroy?.()` and unregisters everything
|
||||
that plugin added (registrations are tracked per pluginId).
|
||||
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
|
||||
|
||||
@@ -63,6 +66,79 @@ interface PluginHostApi {
|
||||
// (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;
|
||||
// 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 {
|
||||
@@ -83,14 +159,85 @@ Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea,
|
||||
Tooltip*) 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`), and `Combobox` (the app's Command+Popover picker). The
|
||||
authoritative list is `apps/web/lib/plugins/host-api.ts` (`PLUGIN_UI`).
|
||||
`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.
|
||||
|
||||
### `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
|
||||
@@ -141,8 +288,12 @@ interface PluginRegistry {
|
||||
// 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", and
|
||||
// "plugin-settings".
|
||||
// "main-top-bar", "app-status-bar-left", "app-status-bar-right",
|
||||
// "plugin-settings", and "task-card-indicators".
|
||||
// "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.
|
||||
// "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`.
|
||||
@@ -189,6 +340,50 @@ interface PluginRegistry {
|
||||
// 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. See
|
||||
// "Kanban card contributions" below.
|
||||
registerTaskMenuAction(registration: TaskMenuActionRegistration): 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;
|
||||
group: "edit"; // only group today — the card's Edit submenu
|
||||
visible?(context: PluginTaskMenuContext): boolean; // default: always visible
|
||||
run(context: PluginTaskMenuContext): void | Promise<void>; // a rejection is caught and logged
|
||||
}
|
||||
```
|
||||
|
||||
@@ -232,6 +427,52 @@ A full-bleed plugin route (`topbar: false`) opts out of host chrome. It may moun
|
||||
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).
|
||||
|
||||
`"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 }`.
|
||||
|
||||
## Registry internals (host side)
|
||||
|
||||
`apps/web/lib/plugins/registry.ts` holds a singleton `PluginRegistry` whose data
|
||||
@@ -239,9 +480,12 @@ 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)`, and
|
||||
`getSettingsRoutes()`, `getSlotComponents(slot)`, `getWsHandlers(action)`,
|
||||
`getPluginName(pluginId)` (display name recorded by `forPlugin(id, name)`, used
|
||||
for derived page-chrome titles).
|
||||
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
|
||||
@@ -265,7 +509,33 @@ defaults, or the bare component when the route opted out (`topbar: false`).
|
||||
(`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 }`.
|
||||
`{ taskId, taskTitle, activeSessionId, sessionIds }`. `kanban-card-content.tsx`
|
||||
hosts `task-card-indicators` beside `PRTaskIcon`.
|
||||
- `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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user