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