diff --git a/apps/web/components/task/chat/chat-input-plugin-actions.test.tsx b/apps/web/components/task/chat/chat-input-plugin-actions.test.tsx
new file mode 100644
index 0000000000..cbde509978
--- /dev/null
+++ b/apps/web/components/task/chat/chat-input-plugin-actions.test.tsx
@@ -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(
+ ,
+ );
+ 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 (
+
+ {`${ctx.taskId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}
+
+ );
+ });
+
+ render();
+
+ 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 {ctx.sessionIds.join(",")}
;
+ });
+
+ // taskId with no store entry -> only the active session propagates.
+ render();
+
+ 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 (
+ {`${ctx.taskId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}
+ );
+ });
+
+ render();
+
+ expect(screen.getByTestId("plugin-icon").textContent).toBe("null|s9|s9");
+ });
+});
diff --git a/apps/web/components/task/chat/chat-input-plugin-actions.tsx b/apps/web/components/task/chat/chat-input-plugin-actions.tsx
new file mode 100644
index 0000000000..0f4fc7f33e
--- /dev/null
+++ b/apps/web/components/task/chat/chat-input-plugin-actions.tsx
@@ -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(() => {
+ 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 ;
+}
diff --git a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx
index 42bf0f46d6..fc529829f5 100644
--- a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx
+++ b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx
@@ -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 && (
)}
+ {!props.hideAgentControls && (
+
+ )}
{props.onVoiceTranscript && (
)}
+ {!props.hideAgentControls && (
+
+ )}
{props.onVoiceTranscript && (
`. 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;
diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md
index df372ceba8..3182eebb71 100644
--- a/docs/plans/plugins/PLUGIN-API.md
+++ b/docs/plans/plugins/PLUGIN-API.md
@@ -123,7 +123,13 @@ interface PluginRegistry {
// Named slot injection. Host renders all components registered for a slot via
//
. 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`: `` 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)
diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md
index 4f4d33fd2e..2c988b96e2 100644
--- a/docs/public/plugins-authoring.md
+++ b/docs/public/plugins-authoring.md
@@ -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
diff --git a/docs/public/plugins.md b/docs/public/plugins.md
index fb7d10a7aa..c0e729f18c 100644
--- a/docs/public/plugins.md
+++ b/docs/public/plugins.md
@@ -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