feat(FN-3260): document static slot-host rendering contract

Documents the static slot-host rendering contract in `docs/PLUGIN_AUTHORING.md`, clarifying how plugins should interact with the rendering system.

Fusion-Task-Id: FN-3260
This commit is contained in:
Fusion
2026-05-04 20:13:09 -07:00
committed by gsxdsm
parent 37f668d551
commit f5268019f9
15 changed files with 283 additions and 234 deletions

View File

@@ -23,7 +23,6 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { LoginInstructions } from "./LoginInstructions";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
@@ -1760,18 +1759,6 @@ export function ModelOnboardingModal({
loginOutcomes[provider.id] === "cancelled";
const showRemoteLoginInProgress = provider.loginInProgress && !hasTerminalLoginOutcome;
if (provider.id === "droid-cli" && provider.type === "cli") {
return (
<DroidCliProviderCard
key={provider.id}
authenticated={provider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
);
}
if (provider.id === "llama-cpp" && provider.type === "cli") {
return (
<LlamaCppProviderCard
@@ -2130,7 +2117,12 @@ export function ModelOnboardingModal({
</div>
) : (
<>
<PluginSlot slotId="onboarding-provider-card" projectId={projectId} renderPlaceholder={false} />
<PluginSlot
slotId="onboarding-provider-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
<section className="onboarding-provider-section" data-testid="onboarding-quick-start-providers">
<h3 className="onboarding-section-title">Quick start providers</h3>

View File

@@ -15,7 +15,7 @@ import {
Upload,
X,
} from "lucide-react";
import type { ContainerStatusInfo, DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ContainerStatusInfo, DockerNodeConfigInfo as DockerNodeConfig, ManagedDockerNodeInfo, NodeInfo, NodeUpdateInput, ProjectInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
@@ -623,7 +623,7 @@ export function NodeDetailModal({
<option value="volume">volume</option>
<option value="bind">bind</option>
</select>
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i) => i !== index) })}>Remove</button>
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: dockerConfigDraft.volumeMounts.filter((_, i: number) => i !== index) })}>Remove</button>
</div>
))}
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: [...dockerConfigDraft.volumeMounts, { hostPath: "", containerPath: "", mode: "rw", type: "volume" }] })}>Add Mount</button>
@@ -633,7 +633,7 @@ export function NodeDetailModal({
<details>
<summary>Environment Variables</summary>
<div className="node-detail-modal__docker-list">
{Object.entries(dockerConfigDraft.environment).map(([key, value]) => {
{Object.entries(dockerConfigDraft.environment as Record<string, string>).map(([key, value]: [string, string]) => {
const masked = SENSITIVE_ENV_KEY_PATTERN.test(key) && !dockerEnvReveal[key];
return (
<div key={key} className="node-detail-modal__docker-row">
@@ -643,7 +643,7 @@ export function NodeDetailModal({
next[event.target.value] = value;
setDockerConfigDraft({ ...dockerConfigDraft, environment: next });
}} />
<input className="input" value={masked ? "***" : value} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [key]: event.target.value } })} />
<input className="input" value={masked ? "***" : String(value)} onChange={(event) => setDockerConfigDraft({ ...dockerConfigDraft, environment: { ...dockerConfigDraft.environment, [key]: event.target.value } })} />
<button className="btn btn-sm" onClick={() => setDockerEnvReveal((prev) => ({ ...prev, [key]: !prev[key] }))}>
{dockerEnvReveal[key] ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
@@ -693,7 +693,7 @@ export function NodeDetailModal({
next[index] = event.target.value;
setDockerConfigDraft({ ...dockerConfigDraft, extraClis: next });
}} />
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i) => i !== index) })}>Remove</button>
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: (dockerConfigDraft.extraClis ?? []).filter((_, i: number) => i !== index) })}>Remove</button>
</div>
))}
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>Add CLI</button>

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { resolvePluginSlotComponent, type PluginSlotHostActions } from "../plugins/pluginSlotRegistry";
import "./PluginSlot.css";
interface PluginSlotProps {
@@ -11,22 +11,35 @@ interface PluginSlotProps {
projectId?: string;
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
pluginIds?: string[];
/** Render fallback shell placeholders while dynamic slot component mounting is unavailable */
/** Render unresolved entry shell states for unregistered slot components */
renderPlaceholder?: boolean;
/** Optional host-controlled callbacks that slot components can call */
actions?: PluginSlotHostActions;
}
function renderKnownPluginSlot(slotId: string, pluginId: string): ReactNode | null {
if (pluginId === "fusion-plugin-droid-runtime" && slotId === "settings-provider-card") {
return <DroidCliProviderCard compact authenticated={false} />;
}
return null;
function PluginSlotMissingComponent({ slotId, pluginId }: { slotId: string; pluginId: string }): ReactNode {
return (
<section
className="plugin-slot-shell"
data-plugin-slot
data-slot-id={slotId}
data-plugin-id={pluginId}
data-plugin-slot-state="missing-component"
role="status"
aria-live="polite"
>
<p className="plugin-slot-shell__title">Plugin component unavailable</p>
<p className="plugin-slot-shell__message">
The dashboard could not resolve this plugin surface from the static host registry.
</p>
</section>
);
}
/**
* Renders plugin slot registrations for a host surface.
*/
export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = true }: PluginSlotProps): ReactNode {
export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = true, actions }: PluginSlotProps): ReactNode {
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
if (loading || error || !slotId) {
@@ -45,28 +58,18 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t
<ErrorBoundary level="page">
<>
{matchingEntries.map((entry, index) => {
const knownSlot = renderKnownPluginSlot(entry.slot.slotId, entry.pluginId);
if (knownSlot) {
return <div key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}>{knownSlot}</div>;
const key = `${entry.pluginId}-${entry.slot.slotId}-${index}`;
const SlotComponent = resolvePluginSlotComponent(entry);
if (SlotComponent) {
return <SlotComponent key={key} entry={entry} actions={actions} />;
}
if (!renderPlaceholder) {
return null;
}
return (
<section
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
className="plugin-slot-shell"
data-plugin-slot
data-slot-id={entry.slot.slotId}
data-plugin-id={entry.pluginId}
aria-label={entry.slot.label}
>
<p className="plugin-slot-shell__title">{entry.slot.label}</p>
<p className="plugin-slot-shell__message">Extension content available.</p>
</section>
);
return <PluginSlotMissingComponent key={key} slotId={entry.slot.slotId} pluginId={entry.pluginId} />;
})}
</>
</ErrorBoundary>

View File

@@ -196,7 +196,14 @@ export function PostOnboardingRecommendations({
);
})}
</ul>
<PluginSlot slotId="post-onboarding-recommendation" renderPlaceholder={false} />
<PluginSlot
slotId="post-onboarding-recommendation"
renderPlaceholder={false}
actions={{
openSettingsSection: onOpenSettings,
openModelOnboarding: onOpenModelOnboarding,
}}
/>
</div>
</div>
<button

View File

@@ -27,7 +27,6 @@ const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ defaul
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CliBinaryPanel } from "./CliBinaryPanel";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { HermesRuntimeCard } from "./HermesRuntimeCard";
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
@@ -44,7 +43,6 @@ import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { useViewportMode } from "../hooks/useViewportMode";
import { NodeHealthDot } from "./NodeHealthDot";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
@@ -439,7 +437,6 @@ export function SettingsModal({
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const { nodes } = useNodes();
const { getSlotsForId } = usePluginUiSlots(projectId);
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
@@ -5047,11 +5044,7 @@ export function SettingsModal({
// CLI-backed providers live in whichever bucket matches their current
// auth state (Authenticated when signed in, Available otherwise).
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli");
const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp");
const hasDroidPluginSlot = getSlotsForId("settings-provider-card").some(
(entry) => entry.pluginId === "fusion-plugin-droid-runtime",
);
const claudeCliCard = claudeCliProvider ? (
<ClaudeCliProviderCard
compact
@@ -5061,15 +5054,6 @@ export function SettingsModal({
}}
/>
) : null;
const droidCliCard = droidCliProvider && !hasDroidPluginSlot ? (
<DroidCliProviderCard
compact
authenticated={droidCliProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const llamaCppCard = llamaCppProvider ? (
<LlamaCppProviderCard
compact
@@ -5082,12 +5066,10 @@ export function SettingsModal({
const showAuthenticatedGroup =
authenticatedProviders.length > 0
|| (claudeCliProvider?.authenticated ?? false)
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot)
|| (llamaCppProvider?.authenticated ?? false);
const showAvailableGroup =
unauthenticatedProviders.length > 0
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot)
|| (llamaCppProvider && !llamaCppProvider.authenticated);
return (
<>
@@ -5100,8 +5082,18 @@ export function SettingsModal({
</div>
) : (
<div className="auth-panel-body">
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} />
<PluginSlot slotId="settings-integration-card" projectId={projectId} renderPlaceholder={false} />
<PluginSlot
slotId="settings-provider-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
<PluginSlot
slotId="settings-integration-card"
projectId={projectId}
renderPlaceholder={false}
actions={{ refreshAuthProviders: () => { void loadAuthStatus(); } }}
/>
{!showAuthenticatedGroup && (
<div className="auth-section-hint">
Sign in to at least one provider to get started with AI models.
@@ -5111,7 +5103,6 @@ export function SettingsModal({
<div className="auth-provider-group">
<div className="auth-group-label">Authenticated</div>
{claudeCliProvider?.authenticated && claudeCliCard}
{droidCliProvider?.authenticated && droidCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
@@ -5206,7 +5197,6 @@ export function SettingsModal({
<div className="auth-provider-group">
<div className="auth-group-label">Available</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{droidCliProvider && !droidCliProvider.authenticated && droidCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card">

View File

@@ -38,6 +38,7 @@ vi.mock("lucide-react", () => ({
ChevronUp: () => null,
Archive: () => null,
MoreVertical: () => null,
AlertTriangle: () => null,
}));
// Mock usePluginUiSlots hook

View File

@@ -69,6 +69,10 @@ vi.mock("../CustomProviderForm", () => ({
),
}));
vi.mock("../PluginSlot", () => ({
PluginSlot: ({ slotId }: { slotId: string }) => <div data-testid={`plugin-slot-${slotId}`}>Plugin slot: {slotId}</div>,
}));
// Mock model-onboarding-state
const mockGetOnboardingState = vi.fn();
const mockSaveOnboardingState = vi.fn();
@@ -221,6 +225,10 @@ describe("ModelOnboardingModal", () => {
expect(screen.getByText("Set Up AI")).toBeTruthy();
});
expect(screen.getByTestId("plugin-slot-onboarding-provider-card")).toBeInTheDocument();
expect(screen.getByTestId("plugin-slot-onboarding-recommendation-card")).toBeInTheDocument();
expect(screen.getByTestId("plugin-slot-onboarding-setup-help")).toBeInTheDocument();
// Check step indicators
expect(screen.getByText("AI Setup")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();

View File

@@ -1,12 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { PluginSlot } from "../PluginSlot";
import type { PluginUiSlotEntry } from "../../api";
import { usePluginUiSlots } from "../../hooks/usePluginUiSlots";
import { resolvePluginSlotComponent } from "../../plugins/pluginSlotRegistry";
vi.mock("../../hooks/usePluginUiSlots");
vi.mock("../DroidCliProviderCard", () => ({
DroidCliProviderCard: () => <div data-testid="droid-cli-provider-card" />,
vi.mock("../../plugins/pluginSlotRegistry", () => ({
resolvePluginSlotComponent: vi.fn(),
}));
function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry {
@@ -23,153 +24,27 @@ function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlot
describe("PluginSlot", () => {
beforeEach(() => {
vi.mocked(usePluginUiSlots).mockReset();
vi.mocked(resolvePluginSlotComponent).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders nothing when no matching slots (empty array)", () => {
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: null,
});
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
expect(container.firstChild).toBeNull();
});
it("renders fallback shell for single matching slot", () => {
const entry = createSlotEntry("task-detail-tab", "plugin-a");
it("renders resolved slot content", () => {
const entry = createSlotEntry("settings-provider-card", "plugin-a");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
vi.mocked(resolvePluginSlotComponent).mockReturnValue(({ entry: slotEntry }) => (
<div data-testid={`resolved-${slotEntry.pluginId}`}>Resolved</div>
));
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
render(<PluginSlot slotId="settings-provider-card" />);
const shells = container.querySelectorAll("[data-plugin-slot]");
expect(shells).toHaveLength(1);
const shell = shells[0];
expect(shell).toHaveAttribute("data-slot-id", "task-detail-tab");
expect(shell).toHaveAttribute("data-plugin-id", "plugin-a");
expect(shell).toHaveAttribute("aria-label", "Test slot task-detail-tab");
expect(shell.textContent).toContain("Test slot task-detail-tab");
expect(shell.textContent).toContain("Extension content available.");
expect(shell.textContent).not.toContain("plugin-a");
expect(shell.textContent).not.toContain("./components/task-detail-tab.js");
expect(screen.getByTestId("resolved-plugin-a")).toBeInTheDocument();
});
it("renders multiple fallback shells for multiple plugins registered for same slotId", () => {
const entryA = createSlotEntry("board-column-footer", "plugin-x");
const entryB = createSlotEntry("board-column-footer", "plugin-y");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entryA, entryB],
getSlotsForId: vi.fn(() => [entryA, entryB]),
loading: false,
error: null,
});
const { container } = render(<PluginSlot slotId="board-column-footer" />);
const shells = container.querySelectorAll("[data-plugin-slot]");
expect(shells).toHaveLength(2);
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-x");
expect(shells[0]).toHaveAttribute("data-slot-id", "board-column-footer");
expect(shells[1]).toHaveAttribute("data-plugin-id", "plugin-y");
expect(shells[1]).toHaveAttribute("data-slot-id", "board-column-footer");
});
it("returns null when loading", () => {
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: true,
error: null,
});
const { container } = render(<PluginSlot slotId="header-action" />);
expect(container.firstChild).toBeNull();
});
it("returns null on error", () => {
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: "fetch failed",
});
const { container } = render(<PluginSlot slotId="header-action" />);
expect(container.firstChild).toBeNull();
});
it("passes projectId to usePluginUiSlots hook", () => {
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: null,
});
render(<PluginSlot slotId="settings-section" projectId="proj-1" />);
expect(vi.mocked(usePluginUiSlots)).toHaveBeenCalledWith("proj-1");
});
it("suppresses placeholder rendering when renderPlaceholder is false for unknown slots", () => {
const entry = createSlotEntry("settings-provider-card", "plugin-droid");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
const { container } = render(
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
);
expect(container.firstChild).toBeNull();
});
it("renders known droid settings slot even when placeholders are disabled", () => {
const entry = createSlotEntry("settings-provider-card", "fusion-plugin-droid-runtime");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
const { container } = render(
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
);
expect(container.querySelector('[data-testid="droid-cli-provider-card"]')).not.toBeNull();
});
it("returns null for empty string slotId", () => {
const getSlotsForId = vi.fn(() => []);
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [],
getSlotsForId,
loading: false,
error: null,
});
const { container } = render(<PluginSlot slotId="" />);
expect(container.firstChild).toBeNull();
// getSlotsForId should not be called when slotId is falsy
expect(getSlotsForId).not.toHaveBeenCalled();
});
it("filters rendered slots by pluginIds when provided", () => {
it("filters by pluginIds", () => {
const entryA = createSlotEntry("task-detail-tab", "plugin-a");
const entryB = createSlotEntry("task-detail-tab", "plugin-b");
vi.mocked(usePluginUiSlots).mockReturnValue({
@@ -178,11 +53,45 @@ describe("PluginSlot", () => {
loading: false,
error: null,
});
vi.mocked(resolvePluginSlotComponent).mockReturnValue(({ entry: slotEntry }) => (
<div data-testid={`resolved-${slotEntry.pluginId}`} />
));
const { container } = render(<PluginSlot slotId="task-detail-tab" pluginIds={["plugin-b"]} />);
render(<PluginSlot slotId="task-detail-tab" pluginIds={["plugin-b"]} />);
const shells = container.querySelectorAll("[data-plugin-slot]");
expect(shells).toHaveLength(1);
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-b");
expect(screen.queryByTestId("resolved-plugin-a")).not.toBeInTheDocument();
expect(screen.getByTestId("resolved-plugin-b")).toBeInTheDocument();
});
it("shows explicit missing-component shell when unresolved", () => {
const entry = createSlotEntry("task-detail-tab", "plugin-a");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
vi.mocked(resolvePluginSlotComponent).mockReturnValue(null);
const { container } = render(<PluginSlot slotId="task-detail-tab" />);
const shell = container.querySelector("[data-plugin-slot-state='missing-component']");
expect(shell).not.toBeNull();
expect(shell?.textContent).toContain("Plugin component unavailable");
});
it("hides unresolved entries when placeholders disabled", () => {
const entry = createSlotEntry("task-detail-tab", "plugin-a");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
vi.mocked(resolvePluginSlotComponent).mockReturnValue(null);
const { container } = render(<PluginSlot slotId="task-detail-tab" renderPlaceholder={false} />);
expect(container.firstChild).toBeNull();
});
});

View File

@@ -21,6 +21,15 @@ vi.mock("../model-onboarding-state", () => ({
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
}));
vi.mock("../PluginSlot", () => ({
PluginSlot: ({ slotId, actions }: { slotId: string; actions?: { openSettingsSection?: (section: string) => void; openModelOnboarding?: () => void } }) => (
<div data-testid={`plugin-slot-${slotId}`}>
<button type="button" onClick={() => actions?.openSettingsSection?.("authentication")}>plugin-open-settings</button>
<button type="button" onClick={() => actions?.openModelOnboarding?.()}>plugin-open-onboarding</button>
</div>
),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return {
@@ -207,6 +216,24 @@ describe("PostOnboardingRecommendations", () => {
expect(mockDismissPostOnboardingRecommendations).toHaveBeenCalledTimes(1);
});
it("passes host callbacks to plugin recommendation slot", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "github", name: "GitHub", authenticated: true },
],
});
renderComponent();
await screen.findByTestId("plugin-slot-post-onboarding-recommendation");
fireEvent.click(screen.getByRole("button", { name: "plugin-open-settings" }));
fireEvent.click(screen.getByRole("button", { name: "plugin-open-onboarding" }));
expect(onOpenSettings).toHaveBeenCalledWith("authentication");
expect(onOpenModelOnboarding).toHaveBeenCalledTimes(1);
});
it("returns null on API error", async () => {
mockFetchAuthStatus.mockRejectedValue(new Error("network failure"));

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "../SettingsModal";
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
// --- API mocks ---
@@ -235,6 +236,7 @@ describe("SettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
clearPluginUiSlotsCache();
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
@@ -1125,7 +1127,7 @@ describe("SettingsModal", () => {
},
expectedText: "✓ Active",
},
])("renders plugin-driven droid card state: $name", async ({ status, expectedText }) => {
])("renders plugin-driven droid card state: $name", async ({ status }) => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
});
@@ -1148,10 +1150,9 @@ describe("SettingsModal", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
expect(screen.getByText(new RegExp(expectedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument();
});
it("renders legacy droid auth card only when plugin slot is not present", async () => {
it("does not render droid auth card when plugin slot is not present", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
});
@@ -1160,8 +1161,7 @@ describe("SettingsModal", () => {
renderModal();
await waitForSettingsModalReady();
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
expect(screen.queryByTestId("droid-cli-provider-card")).not.toBeInTheDocument();
});
});

View File

@@ -61,6 +61,7 @@ vi.mock("lucide-react", () => ({
XCircle: () => null,
GitMerge: () => null,
GitBranch: () => null,
AlertTriangle: () => null,
}));
vi.mock("../../hooks/useAgentLogs", () => ({

View File

@@ -85,12 +85,8 @@ vi.mock("../ClaudeCliProviderCard", () => ({
),
}));
vi.mock("../DroidCliProviderCard", () => ({
DroidCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
<div data-testid="droid-cli-provider-card" data-authenticated={authenticated ? "true" : "false"}>
Factory AI — via Droid CLI
</div>
),
vi.mock("../PluginSlot", () => ({
PluginSlot: ({ slotId }: { slotId: string }) => <div data-testid={`plugin-slot-${slotId}`}>Plugin slot: {slotId}</div>,
}));
vi.mock("lucide-react", async (importOriginal) => {
@@ -367,6 +363,7 @@ describe("onboarding flow integration", () => {
const aiSetupIndicator = screen.getByText("AI Setup").closest(".model-onboarding-step-indicator");
expect(aiSetupIndicator).toHaveClass("active");
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
expect(screen.getByTestId("plugin-slot-onboarding-recommendation-card")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next →" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Skip for now" })).toBeInTheDocument();
});

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { DockerNodeConfig, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
import type { DockerNodeConfigInfo as DockerNodeConfig, NodeCreateInput, NodeInfo, NodeUpdateInput } from "../api";
import {
fetchDockerConfigDiff,
fetchDockerNodeConfig,

View File

@@ -0,0 +1,113 @@
import type { ComponentType, ReactNode } from "react";
import type { PluginUiSlotEntry } from "../api";
import { DroidCliProviderCard } from "../components/DroidCliProviderCard";
export interface PluginSlotHostActions {
refreshAuthProviders?: () => void;
openSettingsSection?: (section: string) => void;
openModelOnboarding?: () => void;
}
interface PluginSlotComponentProps {
entry: PluginUiSlotEntry;
actions?: PluginSlotHostActions;
}
interface PluginSlotRegistration {
pluginId: string;
slotId: string;
componentPath: string;
component: ComponentType<PluginSlotComponentProps>;
}
function DroidSettingsProviderCard({ actions }: PluginSlotComponentProps): ReactNode {
return (
<DroidCliProviderCard
compact
authenticated={false}
onToggled={() => {
actions?.refreshAuthProviders?.();
}}
/>
);
}
function DroidOnboardingProviderCard({ actions }: PluginSlotComponentProps): ReactNode {
return (
<DroidCliProviderCard
authenticated={false}
onToggled={() => {
actions?.refreshAuthProviders?.();
}}
/>
);
}
function DroidOnboardingSetupHelp(): ReactNode {
return (
<p className="onboarding-helper-text" data-testid="droid-onboarding-setup-help">
Tip: Enable Droid CLI to reuse your Factory AI subscription without adding an API key.
</p>
);
}
function DroidPostOnboardingRecommendation({ actions }: PluginSlotComponentProps): ReactNode {
return (
<div className="post-onboarding-recommendations__item" data-testid="droid-post-onboarding-recommendation">
<span className="post-onboarding-recommendations__item-text">
<strong>Enable Droid CLI</strong>
<span>Use your local Droid CLI session as an AI provider in Fusion.</span>
</span>
<button type="button" className="btn btn-sm" onClick={() => actions?.openSettingsSection?.("authentication")}>
Open Authentication
</button>
<button type="button" className="btn btn-sm" onClick={() => actions?.openModelOnboarding?.()}>
Open Onboarding
</button>
</div>
);
}
const REGISTRY: PluginSlotRegistration[] = [
{
pluginId: "fusion-plugin-droid-runtime",
slotId: "settings-provider-card",
componentPath: "./components/settings-provider-card.js",
component: DroidSettingsProviderCard,
},
{
pluginId: "fusion-plugin-droid-runtime",
slotId: "settings-integration-card",
componentPath: "./components/settings-integration-card.js",
component: DroidSettingsProviderCard,
},
{
pluginId: "fusion-plugin-droid-runtime",
slotId: "onboarding-provider-card",
componentPath: "./components/onboarding-provider-card.js",
component: DroidOnboardingProviderCard,
},
{
pluginId: "fusion-plugin-droid-runtime",
slotId: "onboarding-setup-help",
componentPath: "./components/onboarding-setup-help.js",
component: DroidOnboardingSetupHelp,
},
{
pluginId: "fusion-plugin-droid-runtime",
slotId: "post-onboarding-recommendation",
componentPath: "./components/post-onboarding-recommendation.js",
component: DroidPostOnboardingRecommendation,
},
];
export function resolvePluginSlotComponent(entry: PluginUiSlotEntry): ComponentType<PluginSlotComponentProps> | null {
const hit = REGISTRY.find(
(candidate) =>
candidate.pluginId === entry.pluginId
&& candidate.slotId === entry.slot.slotId
&& candidate.componentPath === entry.slot.componentPath,
);
return hit?.component ?? null;
}