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:
@@ -450,11 +450,13 @@ Plugins declare `uiSlots` in their `FusionPlugin` definition. The dashboard disc
|
|||||||
| `settings-section` | Settings modal | Section added to the settings panel | Available |
|
| `settings-section` | Settings modal | Section added to the settings panel | Available |
|
||||||
| `settings-provider-card` | Settings → Authentication | Provider card contribution in Authentication section | Available |
|
| `settings-provider-card` | Settings → Authentication | Provider card contribution in Authentication section | Available |
|
||||||
| `settings-integration-card` | Settings → Authentication | Integration/help card contribution in Authentication section | Available |
|
| `settings-integration-card` | Settings → Authentication | Integration/help card contribution in Authentication section | Available |
|
||||||
|
| `onboarding-provider-card` | Onboarding modal → AI setup | Provider card content rendered before host fallback cards | Available |
|
||||||
|
| `onboarding-recommendation-card` | Onboarding modal → AI setup | Recommendation/help content rendered near setup intro | Available |
|
||||||
|
| `onboarding-setup-help` | Onboarding modal → AI setup | Additional setup-help content rendered below provider sections | Available |
|
||||||
|
| `post-onboarding-recommendation` | Dashboard post-onboarding card | Recommendation item rendered in host-owned next-steps container | Available |
|
||||||
| `task-card-badge` | Task card on the board | Small badge displayed on task cards (e.g., CI status indicator) | Planned |
|
| `task-card-badge` | Task card on the board | Small badge displayed on task cards (e.g., CI status indicator) | Planned |
|
||||||
| `board-column-footer` | Board column | Footer area below the last card in a column | Planned |
|
| `board-column-footer` | Board column | Footer area below the last card in a column | Planned |
|
||||||
|
|
||||||
> **Note:** Slots marked "Planned" are defined in the type system but dashboard rendering is not yet implemented. You can register for these slots now and they will render once the dashboard integration is complete.
|
|
||||||
|
|
||||||
### Defining UI Slots
|
### Defining UI Slots
|
||||||
|
|
||||||
Add `uiSlots` to your `FusionPlugin` definition:
|
Add `uiSlots` to your `FusionPlugin` definition:
|
||||||
@@ -494,19 +496,18 @@ const plugin: FusionPlugin = {
|
|||||||
| `icon` | `string` | No | Lucide icon name for visual identification |
|
| `icon` | `string` | No | Lucide icon name for visual identification |
|
||||||
| `componentPath` | `string` | Yes | Path to the JS module exporting the component, relative to the plugin root |
|
| `componentPath` | `string` | Yes | Path to the JS module exporting the component, relative to the plugin root |
|
||||||
|
|
||||||
### Component Module Format
|
### Component Module Format and Host Resolution
|
||||||
|
|
||||||
The `componentPath` should point to a JS module that exports the component. For the current implementation, the dashboard renders placeholder `div` elements with `data-plugin-slot`, `data-slot-id`, `data-plugin-id`, and `data-component-path` attributes. Full dynamic component loading will be added in a future iteration.
|
`componentPath` is part of the plugin contract, but dashboard rendering is intentionally host-resolved through a **static slot registry** (`pluginId + slotId + componentPath`).
|
||||||
|
|
||||||
Plugin authors should create the component file at the declared path so it's ready when dynamic loading is implemented:
|
Important implications:
|
||||||
|
|
||||||
```javascript
|
- The dashboard does **not** load arbitrary plugin modules at runtime from `componentPath`.
|
||||||
// ./components/ci-badge.js
|
- To render in dashboard flows, your slot entry must match a host-registered mapping.
|
||||||
// Component file (dashboard placeholder rendering for now)
|
- Unknown/unmapped entries degrade safely to a visible “missing component” shell (or render nothing when the host sets `renderPlaceholder={false}`).
|
||||||
export default function CiBadge() {
|
- Host flows still own modal structure, navigation, callbacks, and fallback content when no slot entry exists.
|
||||||
return null; // Placeholder — dynamic loading coming soon
|
|
||||||
}
|
For this reason, plugin authors should still provide stable `componentPath` values in manifests, but coordinate with dashboard host maintainers when adding new UI surfaces or module paths that need mapping.
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
|
||||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||||
import { LoginInstructions } from "./LoginInstructions";
|
import { LoginInstructions } from "./LoginInstructions";
|
||||||
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
||||||
@@ -1760,18 +1759,6 @@ export function ModelOnboardingModal({
|
|||||||
loginOutcomes[provider.id] === "cancelled";
|
loginOutcomes[provider.id] === "cancelled";
|
||||||
const showRemoteLoginInProgress = provider.loginInProgress && !hasTerminalLoginOutcome;
|
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") {
|
if (provider.id === "llama-cpp" && provider.type === "cli") {
|
||||||
return (
|
return (
|
||||||
<LlamaCppProviderCard
|
<LlamaCppProviderCard
|
||||||
@@ -2130,7 +2117,12 @@ export function ModelOnboardingModal({
|
|||||||
</div>
|
</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">
|
<section className="onboarding-provider-section" data-testid="onboarding-quick-start-providers">
|
||||||
<h3 className="onboarding-section-title">Quick start providers</h3>
|
<h3 className="onboarding-section-title">Quick start providers</h3>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 type { ToastType } from "../hooks/useToast";
|
||||||
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
import { getProjectsForNode } from "../utils/nodeProjectAssignment";
|
||||||
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
import type { ComputedNodeSyncStatus } from "../hooks/useNodeSettingsSync";
|
||||||
@@ -623,7 +623,7 @@ export function NodeDetailModal({
|
|||||||
<option value="volume">volume</option>
|
<option value="volume">volume</option>
|
||||||
<option value="bind">bind</option>
|
<option value="bind">bind</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, volumeMounts: [...dockerConfigDraft.volumeMounts, { hostPath: "", containerPath: "", mode: "rw", type: "volume" }] })}>Add Mount</button>
|
<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>
|
<details>
|
||||||
<summary>Environment Variables</summary>
|
<summary>Environment Variables</summary>
|
||||||
<div className="node-detail-modal__docker-list">
|
<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];
|
const masked = SENSITIVE_ENV_KEY_PATTERN.test(key) && !dockerEnvReveal[key];
|
||||||
return (
|
return (
|
||||||
<div key={key} className="node-detail-modal__docker-row">
|
<div key={key} className="node-detail-modal__docker-row">
|
||||||
@@ -643,7 +643,7 @@ export function NodeDetailModal({
|
|||||||
next[event.target.value] = value;
|
next[event.target.value] = value;
|
||||||
setDockerConfigDraft({ ...dockerConfigDraft, environment: next });
|
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] }))}>
|
<button className="btn btn-sm" onClick={() => setDockerEnvReveal((prev) => ({ ...prev, [key]: !prev[key] }))}>
|
||||||
{dockerEnvReveal[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
{dockerEnvReveal[key] ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -693,7 +693,7 @@ export function NodeDetailModal({
|
|||||||
next[index] = event.target.value;
|
next[index] = event.target.value;
|
||||||
setDockerConfigDraft({ ...dockerConfigDraft, extraClis: next });
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>Add CLI</button>
|
<button className="btn btn-sm" onClick={() => setDockerConfigDraft({ ...dockerConfigDraft, extraClis: [...(dockerConfigDraft.extraClis ?? []), ""] })}>Add CLI</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
|
||||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
||||||
|
import { resolvePluginSlotComponent, type PluginSlotHostActions } from "../plugins/pluginSlotRegistry";
|
||||||
import "./PluginSlot.css";
|
import "./PluginSlot.css";
|
||||||
|
|
||||||
interface PluginSlotProps {
|
interface PluginSlotProps {
|
||||||
@@ -11,22 +11,35 @@ interface PluginSlotProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
|
/** Optional plugin IDs to restrict rendering to a subset of matching entries */
|
||||||
pluginIds?: string[];
|
pluginIds?: string[];
|
||||||
/** Render fallback shell placeholders while dynamic slot component mounting is unavailable */
|
/** Render unresolved entry shell states for unregistered slot components */
|
||||||
renderPlaceholder?: boolean;
|
renderPlaceholder?: boolean;
|
||||||
|
/** Optional host-controlled callbacks that slot components can call */
|
||||||
|
actions?: PluginSlotHostActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderKnownPluginSlot(slotId: string, pluginId: string): ReactNode | null {
|
function PluginSlotMissingComponent({ slotId, pluginId }: { slotId: string; pluginId: string }): ReactNode {
|
||||||
if (pluginId === "fusion-plugin-droid-runtime" && slotId === "settings-provider-card") {
|
return (
|
||||||
return <DroidCliProviderCard compact authenticated={false} />;
|
<section
|
||||||
}
|
className="plugin-slot-shell"
|
||||||
|
data-plugin-slot
|
||||||
return null;
|
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.
|
* 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);
|
const { getSlotsForId, loading, error } = usePluginUiSlots(projectId);
|
||||||
|
|
||||||
if (loading || error || !slotId) {
|
if (loading || error || !slotId) {
|
||||||
@@ -45,28 +58,18 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t
|
|||||||
<ErrorBoundary level="page">
|
<ErrorBoundary level="page">
|
||||||
<>
|
<>
|
||||||
{matchingEntries.map((entry, index) => {
|
{matchingEntries.map((entry, index) => {
|
||||||
const knownSlot = renderKnownPluginSlot(entry.slot.slotId, entry.pluginId);
|
const key = `${entry.pluginId}-${entry.slot.slotId}-${index}`;
|
||||||
if (knownSlot) {
|
const SlotComponent = resolvePluginSlotComponent(entry);
|
||||||
return <div key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}>{knownSlot}</div>;
|
|
||||||
|
if (SlotComponent) {
|
||||||
|
return <SlotComponent key={key} entry={entry} actions={actions} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!renderPlaceholder) {
|
if (!renderPlaceholder) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <PluginSlotMissingComponent key={key} slotId={entry.slot.slotId} pluginId={entry.pluginId} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -196,7 +196,14 @@ export function PostOnboardingRecommendations({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<PluginSlot slotId="post-onboarding-recommendation" renderPlaceholder={false} />
|
<PluginSlot
|
||||||
|
slotId="post-onboarding-recommendation"
|
||||||
|
renderPlaceholder={false}
|
||||||
|
actions={{
|
||||||
|
openSettingsSection: onOpenSettings,
|
||||||
|
openModelOnboarding: onOpenModelOnboarding,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const PluginManager = lazy(() => import("./PluginManager").then((m) => ({ defaul
|
|||||||
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m) => ({ default: m.PiExtensionsManager })));
|
||||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||||
import { CliBinaryPanel } from "./CliBinaryPanel";
|
import { CliBinaryPanel } from "./CliBinaryPanel";
|
||||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
|
||||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||||
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
||||||
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
||||||
@@ -44,7 +43,6 @@ import { useConfirm } from "../hooks/useConfirm";
|
|||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useNodes } from "../hooks/useNodes";
|
import { useNodes } from "../hooks/useNodes";
|
||||||
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
|
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { NodeHealthDot } from "./NodeHealthDot";
|
import { NodeHealthDot } from "./NodeHealthDot";
|
||||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||||
@@ -439,7 +437,6 @@ export function SettingsModal({
|
|||||||
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
|
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
|
||||||
|
|
||||||
const { nodes } = useNodes();
|
const { nodes } = useNodes();
|
||||||
const { getSlotsForId } = usePluginUiSlots(projectId);
|
|
||||||
const experimentalFeatures = form.experimentalFeatures ?? {};
|
const experimentalFeatures = form.experimentalFeatures ?? {};
|
||||||
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
||||||
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
|
||||||
@@ -5047,11 +5044,7 @@ export function SettingsModal({
|
|||||||
// CLI-backed providers live in whichever bucket matches their current
|
// CLI-backed providers live in whichever bucket matches their current
|
||||||
// auth state (Authenticated when signed in, Available otherwise).
|
// auth state (Authenticated when signed in, Available otherwise).
|
||||||
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
|
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 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 ? (
|
const claudeCliCard = claudeCliProvider ? (
|
||||||
<ClaudeCliProviderCard
|
<ClaudeCliProviderCard
|
||||||
compact
|
compact
|
||||||
@@ -5061,15 +5054,6 @@ export function SettingsModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
const droidCliCard = droidCliProvider && !hasDroidPluginSlot ? (
|
|
||||||
<DroidCliProviderCard
|
|
||||||
compact
|
|
||||||
authenticated={droidCliProvider.authenticated}
|
|
||||||
onToggled={() => {
|
|
||||||
void loadAuthStatus();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
const llamaCppCard = llamaCppProvider ? (
|
const llamaCppCard = llamaCppProvider ? (
|
||||||
<LlamaCppProviderCard
|
<LlamaCppProviderCard
|
||||||
compact
|
compact
|
||||||
@@ -5082,12 +5066,10 @@ export function SettingsModal({
|
|||||||
const showAuthenticatedGroup =
|
const showAuthenticatedGroup =
|
||||||
authenticatedProviders.length > 0
|
authenticatedProviders.length > 0
|
||||||
|| (claudeCliProvider?.authenticated ?? false)
|
|| (claudeCliProvider?.authenticated ?? false)
|
||||||
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot)
|
|
||||||
|| (llamaCppProvider?.authenticated ?? false);
|
|| (llamaCppProvider?.authenticated ?? false);
|
||||||
const showAvailableGroup =
|
const showAvailableGroup =
|
||||||
unauthenticatedProviders.length > 0
|
unauthenticatedProviders.length > 0
|
||||||
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|
||||||
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot)
|
|
||||||
|| (llamaCppProvider && !llamaCppProvider.authenticated);
|
|| (llamaCppProvider && !llamaCppProvider.authenticated);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -5100,8 +5082,18 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="auth-panel-body">
|
<div className="auth-panel-body">
|
||||||
<PluginSlot slotId="settings-provider-card" projectId={projectId} renderPlaceholder={false} />
|
<PluginSlot
|
||||||
<PluginSlot slotId="settings-integration-card" projectId={projectId} renderPlaceholder={false} />
|
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 && (
|
{!showAuthenticatedGroup && (
|
||||||
<div className="auth-section-hint">
|
<div className="auth-section-hint">
|
||||||
Sign in to at least one provider to get started with AI models.
|
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-provider-group">
|
||||||
<div className="auth-group-label">Authenticated</div>
|
<div className="auth-group-label">Authenticated</div>
|
||||||
{claudeCliProvider?.authenticated && claudeCliCard}
|
{claudeCliProvider?.authenticated && claudeCliCard}
|
||||||
{droidCliProvider?.authenticated && droidCliCard}
|
|
||||||
{llamaCppProvider?.authenticated && llamaCppCard}
|
{llamaCppProvider?.authenticated && llamaCppCard}
|
||||||
{authenticatedProviders.map((provider) => (
|
{authenticatedProviders.map((provider) => (
|
||||||
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
|
<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-provider-group">
|
||||||
<div className="auth-group-label">Available</div>
|
<div className="auth-group-label">Available</div>
|
||||||
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
||||||
{droidCliProvider && !droidCliProvider.authenticated && droidCliCard}
|
|
||||||
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
|
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
|
||||||
{unauthenticatedProviders.map((provider) => (
|
{unauthenticatedProviders.map((provider) => (
|
||||||
<div key={provider.id} className="auth-provider-card">
|
<div key={provider.id} className="auth-provider-card">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
ChevronUp: () => null,
|
ChevronUp: () => null,
|
||||||
Archive: () => null,
|
Archive: () => null,
|
||||||
MoreVertical: () => null,
|
MoreVertical: () => null,
|
||||||
|
AlertTriangle: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock usePluginUiSlots hook
|
// Mock usePluginUiSlots hook
|
||||||
|
|||||||
@@ -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
|
// Mock model-onboarding-state
|
||||||
const mockGetOnboardingState = vi.fn();
|
const mockGetOnboardingState = vi.fn();
|
||||||
const mockSaveOnboardingState = vi.fn();
|
const mockSaveOnboardingState = vi.fn();
|
||||||
@@ -221,6 +225,10 @@ describe("ModelOnboardingModal", () => {
|
|||||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
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
|
// Check step indicators
|
||||||
expect(screen.getByText("AI Setup")).toBeTruthy();
|
expect(screen.getByText("AI Setup")).toBeTruthy();
|
||||||
expect(screen.getByText("GitHub")).toBeTruthy();
|
expect(screen.getByText("GitHub")).toBeTruthy();
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { PluginSlot } from "../PluginSlot";
|
import { PluginSlot } from "../PluginSlot";
|
||||||
import type { PluginUiSlotEntry } from "../../api";
|
import type { PluginUiSlotEntry } from "../../api";
|
||||||
import { usePluginUiSlots } from "../../hooks/usePluginUiSlots";
|
import { usePluginUiSlots } from "../../hooks/usePluginUiSlots";
|
||||||
|
import { resolvePluginSlotComponent } from "../../plugins/pluginSlotRegistry";
|
||||||
|
|
||||||
vi.mock("../../hooks/usePluginUiSlots");
|
vi.mock("../../hooks/usePluginUiSlots");
|
||||||
vi.mock("../DroidCliProviderCard", () => ({
|
vi.mock("../../plugins/pluginSlotRegistry", () => ({
|
||||||
DroidCliProviderCard: () => <div data-testid="droid-cli-provider-card" />,
|
resolvePluginSlotComponent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry {
|
function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry {
|
||||||
@@ -23,153 +24,27 @@ function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlot
|
|||||||
describe("PluginSlot", () => {
|
describe("PluginSlot", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(usePluginUiSlots).mockReset();
|
vi.mocked(usePluginUiSlots).mockReset();
|
||||||
|
vi.mocked(resolvePluginSlotComponent).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it("renders resolved slot content", () => {
|
||||||
vi.restoreAllMocks();
|
const entry = createSlotEntry("settings-provider-card", "plugin-a");
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||||
slots: [entry],
|
slots: [entry],
|
||||||
getSlotsForId: vi.fn(() => [entry]),
|
getSlotsForId: vi.fn(() => [entry]),
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
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(screen.getByTestId("resolved-plugin-a")).toBeInTheDocument();
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders multiple fallback shells for multiple plugins registered for same slotId", () => {
|
it("filters by pluginIds", () => {
|
||||||
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", () => {
|
|
||||||
const entryA = createSlotEntry("task-detail-tab", "plugin-a");
|
const entryA = createSlotEntry("task-detail-tab", "plugin-a");
|
||||||
const entryB = createSlotEntry("task-detail-tab", "plugin-b");
|
const entryB = createSlotEntry("task-detail-tab", "plugin-b");
|
||||||
vi.mocked(usePluginUiSlots).mockReturnValue({
|
vi.mocked(usePluginUiSlots).mockReturnValue({
|
||||||
@@ -178,11 +53,45 @@ describe("PluginSlot", () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
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(screen.queryByTestId("resolved-plugin-a")).not.toBeInTheDocument();
|
||||||
expect(shells).toHaveLength(1);
|
expect(screen.getByTestId("resolved-plugin-b")).toBeInTheDocument();
|
||||||
expect(shells[0]).toHaveAttribute("data-plugin-id", "plugin-b");
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ vi.mock("../model-onboarding-state", () => ({
|
|||||||
ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "first-task"],
|
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) => {
|
vi.mock("lucide-react", async (importOriginal) => {
|
||||||
const actual = await importOriginal() as Record<string, unknown>;
|
const actual = await importOriginal() as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -207,6 +216,24 @@ describe("PostOnboardingRecommendations", () => {
|
|||||||
expect(mockDismissPostOnboardingRecommendations).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("returns null on API error", async () => {
|
||||||
mockFetchAuthStatus.mockRejectedValue(new Error("network failure"));
|
mockFetchAuthStatus.mockRejectedValue(new Error("network failure"));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { SettingsModal } from "../SettingsModal";
|
import { SettingsModal } from "../SettingsModal";
|
||||||
|
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
|
||||||
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
|
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
|
||||||
|
|
||||||
// --- API mocks ---
|
// --- API mocks ---
|
||||||
@@ -235,6 +236,7 @@ describe("SettingsModal", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearPluginUiSlotsCache();
|
||||||
mockUseMobileKeyboard.mockReturnValue({
|
mockUseMobileKeyboard.mockReturnValue({
|
||||||
keyboardOpen: false,
|
keyboardOpen: false,
|
||||||
keyboardOverlap: 0,
|
keyboardOverlap: 0,
|
||||||
@@ -1125,7 +1127,7 @@ describe("SettingsModal", () => {
|
|||||||
},
|
},
|
||||||
expectedText: "✓ Active",
|
expectedText: "✓ Active",
|
||||||
},
|
},
|
||||||
])("renders plugin-driven droid card state: $name", async ({ status, expectedText }) => {
|
])("renders plugin-driven droid card state: $name", async ({ status }) => {
|
||||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
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(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
|
||||||
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
|
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
|
||||||
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
|
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({
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
|
||||||
});
|
});
|
||||||
@@ -1160,8 +1161,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
|
|
||||||
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
|
expect(screen.queryByTestId("droid-cli-provider-card")).not.toBeInTheDocument();
|
||||||
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
XCircle: () => null,
|
XCircle: () => null,
|
||||||
GitMerge: () => null,
|
GitMerge: () => null,
|
||||||
GitBranch: () => null,
|
GitBranch: () => null,
|
||||||
|
AlertTriangle: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||||
|
|||||||
@@ -85,12 +85,8 @@ vi.mock("../ClaudeCliProviderCard", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../DroidCliProviderCard", () => ({
|
vi.mock("../PluginSlot", () => ({
|
||||||
DroidCliProviderCard: ({ authenticated }: { authenticated: boolean }) => (
|
PluginSlot: ({ slotId }: { slotId: string }) => <div data-testid={`plugin-slot-${slotId}`}>Plugin slot: {slotId}</div>,
|
||||||
<div data-testid="droid-cli-provider-card" data-authenticated={authenticated ? "true" : "false"}>
|
|
||||||
Factory AI — via Droid CLI
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("lucide-react", async (importOriginal) => {
|
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");
|
const aiSetupIndicator = screen.getByText("AI Setup").closest(".model-onboarding-step-indicator");
|
||||||
expect(aiSetupIndicator).toHaveClass("active");
|
expect(aiSetupIndicator).toHaveClass("active");
|
||||||
expect(screen.getByText("Set Up AI")).toBeInTheDocument();
|
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: "Next →" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Skip for now" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Skip for now" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
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 {
|
import {
|
||||||
fetchDockerConfigDiff,
|
fetchDockerConfigDiff,
|
||||||
fetchDockerNodeConfig,
|
fetchDockerNodeConfig,
|
||||||
|
|||||||
113
packages/dashboard/app/plugins/pluginSlotRegistry.tsx
Normal file
113
packages/dashboard/app/plugins/pluginSlotRegistry.tsx
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user