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>
80 lines
2.7 KiB
TypeScript
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");
|
|
});
|
|
});
|