feat(plugins): add chat-input-actions slot for plugin toolbar icons (#1794)
* feat(plugins): add chat-input-actions slot for plugin toolbar icons
Let plugins render icon buttons into the chat composer toolbar, beside
the model picker, mic, and send button, via the existing named-slot
mechanism (registerComponent). The host mounts the slot in both the
desktop and mobile toolbars and forwards { taskId, taskTitle,
activeSessionId, sessionIds } as slotProps — the active session plus
every kandev session id on the task. Resolving a session id to an
agent/ACP transcript id is left to the plugin (server-side, via the Host
data API); the host only propagates ids.
Docs (PLUGIN-API.md, public plugins/plugins-authoring) document the new
slot and its props.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): read store optionally in chat-input-actions slot
ChatInputPluginActions read the app store via useAppStore, which throws
outside a StateProvider — breaking existing toolbar tests that render the
composer in isolation. Switch to useOptionalAppStore (fallback to no
sessions) so the slot never hard-requires a provider.
Also: gate the slot behind hideAgentControls to match the other
session-specific toolbar controls, drop a redundant String() coercion,
tidy a dangling docs reference, and cover the taskId=null path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
import {
|
||||
ChatInputPluginActions,
|
||||
type ChatInputActionsSlotProps,
|
||||
} from "./chat-input-plugin-actions";
|
||||
|
||||
const SLOT = "chat-input-actions";
|
||||
|
||||
// Minimal store: the wrapper only reads taskSessionsByTask.itemsByTaskId.
|
||||
const mockState = {
|
||||
taskSessionsByTask: {
|
||||
itemsByTaskId: {
|
||||
t1: [
|
||||
{ id: "s1", task_id: "t1" },
|
||||
{ id: "s2", task_id: "t1" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useOptionalAppStore: (selector: (s: typeof mockState) => unknown) => selector(mockState),
|
||||
}));
|
||||
|
||||
describe("ChatInputPluginActions", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
pluginRegistry.unregisterPlugin("plugin-a");
|
||||
});
|
||||
|
||||
it("renders nothing when no plugin registered a chat-input-actions component", () => {
|
||||
const { container } = render(
|
||||
<ChatInputPluginActions sessionId="s1" taskId="t1" taskTitle="Demo" />,
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("forwards task context, the active session, and all session ids", () => {
|
||||
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
|
||||
const ctx = slotProps as ChatInputActionsSlotProps;
|
||||
return (
|
||||
<div data-testid="plugin-icon">
|
||||
{`${ctx.taskId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
render(<ChatInputPluginActions sessionId="s2" taskId="t1" taskTitle="Demo" />);
|
||||
|
||||
expect(screen.getByTestId("plugin-icon").textContent).toBe("t1|s2|s1,s2");
|
||||
});
|
||||
|
||||
it("includes the active session id even when the store list omits it", () => {
|
||||
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
|
||||
const ctx = slotProps as ChatInputActionsSlotProps;
|
||||
return <div data-testid="plugin-icon">{ctx.sessionIds.join(",")}</div>;
|
||||
});
|
||||
|
||||
// taskId with no store entry -> only the active session propagates.
|
||||
render(<ChatInputPluginActions sessionId="s9" taskId="t-unknown" taskTitle="Demo" />);
|
||||
|
||||
expect(screen.getByTestId("plugin-icon").textContent).toBe("s9");
|
||||
});
|
||||
|
||||
it("propagates only the active session when taskId is null", () => {
|
||||
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
|
||||
const ctx = slotProps as ChatInputActionsSlotProps;
|
||||
return (
|
||||
<div data-testid="plugin-icon">{`${ctx.taskId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}</div>
|
||||
);
|
||||
});
|
||||
|
||||
render(<ChatInputPluginActions sessionId="s9" taskId={null} />);
|
||||
|
||||
expect(screen.getByTestId("plugin-icon").textContent).toBe("null|s9|s9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useOptionalAppStore } from "@/components/state-provider";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { TaskSession } from "@/lib/types/http";
|
||||
|
||||
/**
|
||||
* Props forwarded to every plugin component registered for the
|
||||
* `chat-input-actions` slot (`registry.registerComponent("chat-input-actions",
|
||||
* Component)`). A task can hold several sessions; the composer targets one at a
|
||||
* time (`activeSessionId`), so both it and the full `sessionIds` list are
|
||||
* provided.
|
||||
*
|
||||
* These are kandev session ids. Resolving them to an agent/ACP transcript id
|
||||
* (e.g. to key tokscale cost data on a session) is the plugin's job — do it
|
||||
* server-side in the plugin backend through the Host data API, not here. The
|
||||
* host only propagates the ids. See PLUGIN-API.md.
|
||||
*/
|
||||
export type ChatInputActionsSlotProps = {
|
||||
/** Task the composer belongs to, or null for task-less quick chat. */
|
||||
taskId: string | null;
|
||||
/** Display title of the task, when known. */
|
||||
taskTitle?: string;
|
||||
/** Session the composer is currently bound to, or null before one exists. */
|
||||
activeSessionId: string | null;
|
||||
/** Every kandev session id on the task (includes `activeSessionId`). */
|
||||
sessionIds: string[];
|
||||
};
|
||||
|
||||
const EMPTY_SESSIONS: TaskSession[] = [];
|
||||
|
||||
/**
|
||||
* Plugin extension point in the chat input toolbar, rendered alongside the
|
||||
* first-party controls (model picker, mic, send). Renders every plugin
|
||||
* component registered for the `chat-input-actions` slot (each isolated behind
|
||||
* its own error boundary via `PluginSlot`) and forwards the current task and
|
||||
* all of its session ids as `slotProps`.
|
||||
*/
|
||||
export function ChatInputPluginActions(props: {
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
}) {
|
||||
const { sessionId, taskId, taskTitle } = props;
|
||||
// itemsByTaskId holds a stable per-task array reference (updated only when
|
||||
// that task's sessions change), so selecting it avoids a new-array-per-render.
|
||||
// Read optionally: the composer always renders under a StateProvider in the
|
||||
// app, but rendering the toolbar in isolation (unit tests) must not crash.
|
||||
const selectSessions = useCallback(
|
||||
(s: AppState): TaskSession[] =>
|
||||
taskId ? (s.taskSessionsByTask.itemsByTaskId[taskId] ?? EMPTY_SESSIONS) : EMPTY_SESSIONS,
|
||||
[taskId],
|
||||
);
|
||||
const taskSessions = useOptionalAppStore(selectSessions, EMPTY_SESSIONS);
|
||||
|
||||
const slotProps = useMemo<ChatInputActionsSlotProps>(() => {
|
||||
const sessionIds: string[] = taskSessions.map((session) => session.id);
|
||||
// The active session may not yet be in the store list (freshly prepared);
|
||||
// make sure the plugin always receives it.
|
||||
if (sessionId && !sessionIds.includes(sessionId)) sessionIds.unshift(sessionId);
|
||||
return { taskId, taskTitle, activeSessionId: sessionId, sessionIds };
|
||||
}, [taskSessions, sessionId, taskId, taskTitle]);
|
||||
|
||||
return <PluginSlot name="chat-input-actions" slotProps={slotProps} />;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ResetContextButton } from "./reset-context-button";
|
||||
import { ImplementPlanButton } from "./implement-plan-button";
|
||||
import { ChatInputPluginActions } from "./chat-input-plugin-actions";
|
||||
import { VoiceInputButton } from "./voice-input-button";
|
||||
import { ContextPopover } from "./context-popover";
|
||||
import {
|
||||
@@ -135,6 +136,9 @@ function DesktopRightSection(props: {
|
||||
showCollapsed: boolean;
|
||||
rightItems: ToolbarItemConfig[];
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
hideAgentControls: boolean;
|
||||
planModeEnabled: boolean;
|
||||
isAgentBusy: boolean;
|
||||
hasContent: boolean;
|
||||
@@ -157,6 +161,13 @@ function DesktopRightSection(props: {
|
||||
{props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
|
||||
<ImplementPlanButton onClick={props.onImplementPlan} />
|
||||
)}
|
||||
{!props.hideAgentControls && (
|
||||
<ChatInputPluginActions
|
||||
sessionId={props.sessionId}
|
||||
taskId={props.taskId}
|
||||
taskTitle={props.taskTitle}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
{props.onVoiceTranscript && (
|
||||
<VoiceInputButton
|
||||
@@ -245,6 +256,9 @@ export function DesktopChatInputToolbar(props: DesktopToolbarProps) {
|
||||
showCollapsed={showCollapsed}
|
||||
rightItems={rightItems}
|
||||
sessionId={props.sessionId}
|
||||
taskId={props.taskId}
|
||||
taskTitle={props.taskTitle}
|
||||
hideAgentControls={props.hideAgentControls}
|
||||
planModeEnabled={props.planModeEnabled}
|
||||
isAgentBusy={props.isAgentBusy}
|
||||
hasContent={props.hasContent ?? false}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SessionsDropdown } from "@/components/task/sessions-dropdown";
|
||||
import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display";
|
||||
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
|
||||
import { VoiceInputButton } from "./voice-input-button";
|
||||
import { ChatInputPluginActions } from "./chat-input-plugin-actions";
|
||||
import { ContextPopover } from "./context-popover";
|
||||
import { ImplementPlanButton } from "./implement-plan-button";
|
||||
import { ResetContextButton } from "./reset-context-button";
|
||||
@@ -164,6 +165,13 @@ export function MobileChatInputToolbar(props: MobileToolbarProps) {
|
||||
{props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
|
||||
<ImplementPlanButton onClick={props.onImplementPlan} />
|
||||
)}
|
||||
{!props.hideAgentControls && (
|
||||
<ChatInputPluginActions
|
||||
sessionId={props.sessionId}
|
||||
taskId={props.taskId}
|
||||
taskTitle={props.taskTitle}
|
||||
/>
|
||||
)}
|
||||
{props.onVoiceTranscript && (
|
||||
<VoiceInputButton
|
||||
onTranscript={props.onVoiceTranscript}
|
||||
|
||||
@@ -71,8 +71,10 @@ export interface PluginRouteOptions {
|
||||
|
||||
/**
|
||||
* Named slot the host renders via `<PluginSlot name .../>`. Initial slots:
|
||||
* "task-sidebar", "settings-nav", "main-nav-footer". Not a closed union —
|
||||
* hosts may register additional slot names.
|
||||
* "task-sidebar", "settings-nav", "main-nav-footer", and "chat-input-actions"
|
||||
* (icon buttons in the chat composer toolbar, beside the model picker / mic /
|
||||
* send — receives `{ taskId, taskTitle, activeSessionId, sessionIds }` as
|
||||
* `slotProps`). Not a closed union — hosts may register additional slot names.
|
||||
*/
|
||||
export type PluginSlotName = string;
|
||||
|
||||
|
||||
@@ -123,7 +123,13 @@ interface PluginRegistry {
|
||||
|
||||
// Named slot injection. Host renders all components registered for a slot via
|
||||
// <PluginSlot name="..." props={...}/>. Initial slots: "task-sidebar",
|
||||
// "settings-nav", "main-nav-footer".
|
||||
// "settings-nav", "main-nav-footer", "chat-input-actions". The last renders
|
||||
// icon buttons in the chat composer toolbar (beside the model picker, mic,
|
||||
// and send) and forwards `{ taskId, taskTitle, activeSessionId, sessionIds }`
|
||||
// as `slotProps` — the active session plus every kandev session id on the
|
||||
// task. 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.
|
||||
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
|
||||
|
||||
// WS action handler. Bridged into the existing lib/ws dispatch; called with the
|
||||
@@ -161,6 +167,11 @@ defaults, or the bare component when the route opted out (`topbar: false`).
|
||||
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 }`.
|
||||
|
||||
## Security posture (documented, enforced where cheap)
|
||||
|
||||
|
||||
@@ -234,7 +234,8 @@ interface PluginRegistry {
|
||||
registerNavItem(item: NavItem): void;
|
||||
// Route under /settings/plugins/{id}/..., rendered inside the settings shell.
|
||||
registerSettingsRoute(path: string, Component: React.ComponentType): void;
|
||||
// Named slot injection. Initial slots: "task-sidebar", "settings-nav", "main-nav-footer".
|
||||
// Named slot injection. Initial slots: "task-sidebar", "settings-nav",
|
||||
// "main-nav-footer", "chat-input-actions" (see "Chat toolbar actions" below).
|
||||
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
|
||||
// WS action handler, bridged into the existing lib/ws dispatch.
|
||||
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
|
||||
@@ -311,6 +312,83 @@ here for routes that opt out and render their own chrome), and
|
||||
create-task flow (repo/branch/agent pickers, validation) instead of POSTing
|
||||
directly. See `apps/web/lib/plugins/host-api.ts` for the exact current list.
|
||||
|
||||
## Named slots
|
||||
|
||||
`registerComponent(slot, Component)` injects a component into a host-defined
|
||||
slot. The host renders every plugin's component for that slot (each isolated
|
||||
behind an error boundary), so a slot may hold contributions from several
|
||||
plugins at once. Available slots:
|
||||
|
||||
| Slot | Where it renders | `slotProps` |
|
||||
| --- | --- | --- |
|
||||
| `task-sidebar` | Bottom of the task-detail sidebar | — |
|
||||
| `settings-nav` | Settings navigation tree | — |
|
||||
| `main-nav-footer` | Footer of the main sidebar | — |
|
||||
| `chat-input-actions` | Chat composer toolbar, beside the model picker, mic, and send button | `{ taskId, taskTitle, activeSessionId, sessionIds }` |
|
||||
|
||||
### Chat toolbar actions
|
||||
|
||||
Register a `chat-input-actions` component to add an icon button to the chat
|
||||
composer toolbar. The host passes the current context as `slotProps`:
|
||||
|
||||
```ts
|
||||
type ChatInputActionsSlotProps = {
|
||||
taskId: string | null;
|
||||
taskTitle?: string;
|
||||
activeSessionId: string | null; // session the composer is bound to
|
||||
sessionIds: string[]; // every kandev session id on the task
|
||||
};
|
||||
```
|
||||
|
||||
A task can hold several sessions, so both the active session and the full
|
||||
`sessionIds` list are provided. These are **kandev** session ids — resolving one
|
||||
to an agent/ACP transcript id (for example, to key per-session cost data from a
|
||||
tool like tokscale) is your plugin's job. Do that **server-side in your plugin
|
||||
backend** via the Host data API (`host.Sessions()` exposes each session's
|
||||
`ACPSessionID`), not in the bundle: propagate the ids from the UI to your
|
||||
backend over `host.api.fetch(...)`, and let the backend do the matching.
|
||||
|
||||
```js
|
||||
function makeChatAction(host) {
|
||||
const { jsx: h, ui } = host;
|
||||
const { Button, Tooltip, TooltipTrigger, TooltipContent } = ui;
|
||||
|
||||
return function ChatAction({ slotProps }) {
|
||||
const { taskId } = slotProps ?? {};
|
||||
return h(
|
||||
Tooltip,
|
||||
null,
|
||||
h(
|
||||
TooltipTrigger,
|
||||
{ asChild: true },
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: "button",
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
className: "h-7 w-7 cursor-pointer hover:bg-muted/40",
|
||||
"aria-label": "Open plugin page",
|
||||
onClick: () => host.navigate("/hello-world"),
|
||||
},
|
||||
/* an icon element built with host.jsx */ myIcon(h),
|
||||
),
|
||||
),
|
||||
h(TooltipContent, null, taskId ? `Task: ${taskId}` : "Plugin action"),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// inside initialize(registry, host):
|
||||
registry.registerComponent("chat-input-actions", makeChatAction(host));
|
||||
```
|
||||
|
||||
Match the first-party toolbar buttons: `Button` from `host.ui` with
|
||||
`variant="ghost"`, `size="icon"`, `h-7 w-7`, `cursor-pointer`, and a 16px
|
||||
(`h-4 w-4`) icon. Wrap it in `host.ui.Tooltip` so it reads like the native
|
||||
mic/attach controls. `kandev-plugin-hello/ui/bundle.js` ships a working
|
||||
example.
|
||||
|
||||
## Three integration patterns
|
||||
|
||||
Both example repos are full, working plugins — read them rather than
|
||||
|
||||
@@ -34,7 +34,10 @@ A native UI bundle can register a nav item that renders as a top-level
|
||||
sidebar entry or, when it declares itself part of the Integrations section,
|
||||
alongside kandev's first-party integration links in the main sidebar's
|
||||
**Integrations** section — expect new entries to appear there once such a
|
||||
plugin is installed and active.
|
||||
plugin is installed and active. Bundles can also inject components into
|
||||
host-defined slots, including **icon buttons in the chat composer toolbar**
|
||||
(beside the model picker, mic, and send button), so an active plugin can add
|
||||
its own action right where you message an agent.
|
||||
|
||||
## Installing a plugin
|
||||
|
||||
|
||||
Reference in New Issue
Block a user