4b1320ffb0
* 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>
68 lines
3.0 KiB
TypeScript
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} />;
|
|
}
|