feat(plugins): add voice extraction host APIs (#2548)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 11:14:50 +02:00
committed by GitHub
parent 723c140013
commit 6033d5717c
40 changed files with 1609 additions and 283 deletions
+101 -29
View File
@@ -40,9 +40,10 @@ they must use `host.React` (NOT bundle their own React) to share the host instan
```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)
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;
@@ -56,7 +57,7 @@ interface PluginHostApi {
// dev/desktop base URL from window internals.
baseUrl: string;
};
ui: Record<string, unknown>; // curated @kandev/ui components + app UI (see below)
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
@@ -89,7 +90,11 @@ interface PluginHostApi {
storage: PluginStorageApi;
}
interface PluginStorageEntry { key: string; value: unknown; updatedAt: string; }
interface PluginStorageEntry {
key: string;
value: unknown;
updatedAt: string;
}
interface PluginStorageSetOptions {
// Optimistic-concurrency guard: the updatedAt the caller last read. The
@@ -115,20 +120,37 @@ interface PluginStorageSetOptions {
}
// Mirrors the backend's user-state route scopes.
type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository";
type PluginStorageScope =
| "instance"
| "workspace"
| "task"
| "session"
| "repository";
interface PluginStorageApi {
get(scope: PluginStorageScope, scopeId: string, key: string): Promise<PluginStorageEntry | undefined>;
get(
scope: PluginStorageScope,
scopeId: string,
key: string,
): Promise<PluginStorageEntry | undefined>;
set(
scope: PluginStorageScope, scopeId: string, key: string, value: unknown,
scope: PluginStorageScope,
scopeId: string,
key: string,
value: unknown,
options?: PluginStorageSetOptions,
): Promise<{ updatedAt: string }>;
delete(
scope: PluginStorageScope, scopeId: string, key: string,
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[]>;
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
@@ -145,7 +167,12 @@ interface PluginStorageApi {
// 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 },
filter: {
scope?: PluginStorageScope;
scopeId?: string;
key?: string;
writerId?: string;
},
handler: (change: PluginUserStateChange) => void,
): () => void;
}
@@ -159,10 +186,10 @@ interface PluginUserStateChange {
}
interface PluginModalOptions {
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
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
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 {
@@ -261,7 +288,11 @@ interface RichTextEditorProps {
testId?: string;
}
// RichTextReadOnly: renders markdown read-only, no taskId dependency.
interface RichTextReadOnlyProps { value: string; className?: string; testId?: string; }
interface RichTextReadOnlyProps {
value: string;
className?: string;
testId?: string;
}
```
Deliberately narrow — not the plan editor's `comments`, `onSelectionChange`,
@@ -324,16 +355,22 @@ own write).
// "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"; }
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")
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
}
@@ -349,7 +386,11 @@ interface PluginRegistry {
// 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;
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
@@ -398,7 +439,10 @@ interface PluginRegistry {
// 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;
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.
@@ -442,6 +486,32 @@ interface PluginRegistry {
type PluginPresentation = "desktop" | "mobile";
type PluginComposerSurface =
| "task-chat"
| "quick-chat"
| "task-create"
| "new-session";
interface PluginComposerCapability {
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
focus(): { status: "focused" | "unavailable" };
submit(): Promise<{
status: "submitted" | "blocked" | "unavailable";
reason?: string;
}>;
}
interface PluginComposerSlotProps {
surface: PluginComposerSurface;
presentation: PluginPresentation;
taskId: string | null;
taskTitle?: string;
activeSessionId: string | null;
sessionIds: string[];
disabled: boolean;
submittable: boolean;
disabledReason?: string;
composer: PluginComposerCapability;
}
interface PluginTaskPanelProps {
panelId: string; // this registration's panel id, so one Component can back multiple panels
taskId: string;
@@ -450,9 +520,9 @@ interface PluginTaskPanelProps {
}
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)
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.
}
@@ -488,7 +558,7 @@ interface PluginTaskFilterOption {
}
interface TaskFilterRegistration {
id: string; // plugin-local filter id (unique within the plugin, not globally)
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
@@ -652,8 +722,10 @@ defaults, or the bare component when the route opted out (`topbar: false`).
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`
`chat-input-actions` slot for task and Quick Chat, passing the typed
`PluginComposerSlotProps`. Task creation and new-session creation mount
`task-create-input-actions` and `new-session-input-actions` with the same
capability contract. `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` /