Files
kandev/apps/web/components/task/chat/chat-input-plugin-actions.test.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

80 lines
2.7 KiB
TypeScript

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