Files
kandev/apps/web/components/task/chat/chat-input-plugin-actions.tsx
T
João Salavisa 4b1320ffb0 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>
2026-07-19 12:26:50 +01:00

68 lines
3.0 KiB
TypeScript

"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} />;
}