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:
João Salavisa
2026-07-19 12:26:50 +01:00
committed by GitHub
parent 1dc96a92c6
commit 4b1320ffb0
8 changed files with 267 additions and 5 deletions
@@ -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}
+4 -2
View File
@@ -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;
+12 -1
View File
@@ -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)
+79 -1
View File
@@ -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
+4 -1
View File
@@ -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