diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx
index 7a1f046a44..1321701a66 100644
--- a/apps/web/src/components/EntryShell.tsx
+++ b/apps/web/src/components/EntryShell.tsx
@@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
defaultScenarioPluginIdForKind,
type ConnectorDetail,
+ type InstalledPluginRecord,
} from '@open-design/contracts';
import { LOCALE_LABEL, LOCALES, useI18n, useT, type Locale } from '../i18n';
import { navigate, useRoute } from '../router';
@@ -40,6 +41,7 @@ import { HomeView } from './HomeView';
import {
buildPluginAuthoringPrompt,
createPluginAuthoringHandoff,
+ createPluginUseHandoff,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import { Icon } from './Icon';
@@ -279,6 +281,11 @@ export function EntryShell({
changeView('home');
}
+ function usePluginFromLibrary(record: InstalledPluginRecord) {
+ setHomePromptHandoff(createPluginUseHandoff(Date.now(), record.id));
+ changeView('home');
+ }
+
useEffect(() => {
setIntegrationTab(integrationInitialTab);
}, [integrationInitialTab]);
@@ -721,6 +728,7 @@ export function EntryShell({
{view === 'plugins' ? (
) : null}
diff --git a/apps/web/src/components/HomeView.tsx b/apps/web/src/components/HomeView.tsx
index 0f33d98ef1..7eca7b1c51 100644
--- a/apps/web/src/components/HomeView.tsx
+++ b/apps/web/src/components/HomeView.tsx
@@ -68,6 +68,11 @@ interface PendingReplacement {
confirm: () => void;
}
+interface PendingPluginUseHandoff {
+ pluginId: string;
+ inputs?: Record;
+}
+
const AUTHORING_DEFAULT_SCENARIO_INPUTS = {
artifactKind: 'Open Design plugin',
audience: 'Open Design plugin authors',
@@ -111,6 +116,8 @@ export function HomeView({
const [pendingChipId, setPendingChipId] = useState(null);
const [pendingAuthoringChipId, setPendingAuthoringChipId] = useState(null);
const [pendingAuthoringPrompt, setPendingAuthoringPrompt] = useState(PLUGIN_AUTHORING_PROMPT);
+ const [pendingPluginUseHandoff, setPendingPluginUseHandoff] =
+ useState(null);
const [fallbackProjectKind, setFallbackProjectKind] = useState(null);
const [active, setActive] = useState(null);
const [activeSkill, setActiveSkill] = useState(null);
@@ -156,18 +163,27 @@ export function HomeView({
useEffect(() => {
if (!promptHandoff || consumedHandoffIdRef.current === promptHandoff.id) return;
consumedHandoffIdRef.current = promptHandoff.id;
+ setError(null);
+ if (promptHandoff.source === 'plugin-use') {
+ setPendingPluginUseHandoff({
+ pluginId: promptHandoff.pluginId,
+ ...(promptHandoff.inputs ? { inputs: promptHandoff.inputs } : {}),
+ });
+ if (promptHandoff.focus) {
+ requestAnimationFrame(() => inputRef.current?.focus());
+ }
+ return;
+ }
+
setActive(null);
setActiveSkill(null);
setSelectedPluginContexts([]);
- setError(null);
- setFallbackProjectKind(promptHandoff.source === 'plugin-authoring' ? 'other' : null);
+ setFallbackProjectKind('other');
setPrompt(promptHandoff.prompt);
if (promptHandoff.focus) {
requestAnimationFrame(() => inputRef.current?.focus());
}
- if (promptHandoff.source === 'plugin-authoring') {
- setPendingAuthoringChipId('plugin-authoring');
- }
+ setPendingAuthoringChipId('plugin-authoring');
}, [promptHandoff]);
const contextItemCount = useMemo(
@@ -349,6 +365,26 @@ export function HomeView({
return renderPluginBriefTemplate(query, hydratePluginInputs(record.manifest?.od?.inputs ?? [], inputs));
}
+ useEffect(() => {
+ if (!pendingPluginUseHandoff || pluginsLoading) return;
+ const record = plugins.find((plugin) => plugin.id === pendingPluginUseHandoff.pluginId);
+ setPendingPluginUseHandoff(null);
+ if (!record) {
+ setError(
+ `Plugin "${pendingPluginUseHandoff.pluginId}" is not installed. Refresh Plugins and try again.`,
+ );
+ return;
+ }
+ requestUsePlugin(
+ record,
+ undefined,
+ pendingPluginUseHandoff.inputs
+ ? { inputs: pendingPluginUseHandoff.inputs }
+ : undefined,
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pendingPluginUseHandoff, pluginsLoading, plugins]);
+
function addPluginContext(record: InstalledPluginRecord, nextPrompt: string | null) {
setSelectedPluginContexts((prev) => {
if (prev.some((item) => item.record.id === record.id)) return prev;
diff --git a/apps/web/src/components/PluginsView.tsx b/apps/web/src/components/PluginsView.tsx
index 3486ee552e..cf210ad835 100644
--- a/apps/web/src/components/PluginsView.tsx
+++ b/apps/web/src/components/PluginsView.tsx
@@ -86,6 +86,7 @@ const PLUGIN_SHARE_DETAILS: Record void;
+ onUsePlugin?: (record: InstalledPluginRecord) => void;
onCreatePluginShareProject?: (
pluginId: string,
action: PluginShareAction,
@@ -95,6 +96,7 @@ interface PluginsViewProps {
export function PluginsView({
onCreatePlugin,
+ onUsePlugin,
onCreatePluginShareProject,
}: PluginsViewProps) {
const { locale } = useI18n();
@@ -161,6 +163,11 @@ export function PluginsView({
}
async function handleUsePlugin(record: InstalledPluginRecord) {
+ if (onUsePlugin) {
+ setDetailsRecord(null);
+ onUsePlugin(record);
+ return;
+ }
setPendingApplyId(record.id);
setNotice(null);
const result = await applyPlugin(record.id, { locale });
diff --git a/apps/web/src/components/home-hero/plugin-authoring.ts b/apps/web/src/components/home-hero/plugin-authoring.ts
index a87364b3c0..aa2d596da5 100644
--- a/apps/web/src/components/home-hero/plugin-authoring.ts
+++ b/apps/web/src/components/home-hero/plugin-authoring.ts
@@ -1,9 +1,17 @@
-export interface HomePromptHandoff {
- id: number;
- prompt: string;
- focus: boolean;
- source: 'plugin-authoring';
-}
+export type HomePromptHandoff =
+ | {
+ id: number;
+ prompt: string;
+ focus: boolean;
+ source: 'plugin-authoring';
+ }
+ | {
+ id: number;
+ pluginId: string;
+ focus: boolean;
+ source: 'plugin-use';
+ inputs?: Record;
+ };
export const PLUGIN_AUTHORING_PROMPT = [
'Create an Open Design plugin for: .',
@@ -44,3 +52,17 @@ export function createPluginAuthoringHandoff(
source: 'plugin-authoring',
};
}
+
+export function createPluginUseHandoff(
+ id: number,
+ pluginId: string,
+ inputs?: Record,
+): HomePromptHandoff {
+ return {
+ id,
+ pluginId,
+ ...(inputs ? { inputs } : {}),
+ focus: true,
+ source: 'plugin-use',
+ };
+}
diff --git a/apps/web/tests/components/HomeView.prefill.test.tsx b/apps/web/tests/components/HomeView.prefill.test.tsx
index 3b13345e8b..41f808e0bc 100644
--- a/apps/web/tests/components/HomeView.prefill.test.tsx
+++ b/apps/web/tests/components/HomeView.prefill.test.tsx
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { HomeView } from '../../src/components/HomeView';
import {
createPluginAuthoringHandoff,
+ createPluginUseHandoff,
PLUGIN_AUTHORING_PROMPT,
} from '../../src/components/home-hero/plugin-authoring';
@@ -200,6 +201,46 @@ describe('HomeView prompt handoff', () => {
expect(screen.queryByRole('alert')).toBeNull();
});
+ it('applies a plugin-use handoff from the Plugins page', async () => {
+ const fetchMock = vi.fn(async (url) => {
+ if (typeof url === 'string' && url === '/api/plugins') {
+ return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ if (typeof url === 'string' && url.includes('/apply')) {
+ return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected fetch ${url}`);
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ });
+
+ render(
+ undefined}
+ onOpenProject={() => undefined}
+ onViewAllProjects={() => undefined}
+ promptHandoff={createPluginUseHandoff(1, 'example-web-prototype')}
+ />,
+ );
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
+ '/api/plugins/example-web-prototype/apply',
+ expect.anything(),
+ ));
+ expect((await screen.findByTestId('home-hero-input') as HTMLTextAreaElement).value)
+ .toBe('Create a plugin.');
+ });
+
it('routes free-form submits through the hidden default plugin without applying a visible chip', async () => {
const fetchMock = vi.fn(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
@@ -400,6 +441,61 @@ describe('HomeView prompt handoff', () => {
));
});
+ it('confirms before a plugin-use handoff replaces an existing prompt', async () => {
+ const fetchMock = vi.fn(async (url) => {
+ if (typeof url === 'string' && url === '/api/plugins') {
+ return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ if (typeof url === 'string' && url.includes('/apply')) {
+ return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected fetch ${url}`);
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ });
+
+ const { rerender } = render(
+ undefined}
+ onOpenProject={() => undefined}
+ onViewAllProjects={() => undefined}
+ />,
+ );
+
+ const input = await screen.findByTestId('home-hero-input');
+ fireEvent.change(input, { target: { value: 'Keep my current brief' } });
+
+ rerender(
+ undefined}
+ onOpenProject={() => undefined}
+ onViewAllProjects={() => undefined}
+ promptHandoff={createPluginUseHandoff(2, 'example-web-prototype')}
+ />,
+ );
+
+ expect(await screen.findByRole('dialog', { name: /replace current prompt/i })).toBeTruthy();
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
+ '/api/plugins/example-web-prototype/apply',
+ expect.anything(),
+ ));
+ });
+
it('binds od-plugin-authoring before submitting the rail create-plugin prompt', async () => {
const fetchMock = vi.fn(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
diff --git a/apps/web/tests/components/PluginsView.test.tsx b/apps/web/tests/components/PluginsView.test.tsx
index 077a151126..78ecbc7d5a 100644
--- a/apps/web/tests/components/PluginsView.test.tsx
+++ b/apps/web/tests/components/PluginsView.test.tsx
@@ -203,6 +203,19 @@ describe('PluginsView', () => {
expect(screen.getByText('Example Catalog')).toBeTruthy();
});
+ it('hands installed plugin Use actions to the host shell', async () => {
+ const onUsePlugin = vi.fn();
+ render();
+
+ fireEvent.click(await screen.findByTestId('plugins-home-use-user-plugin'));
+
+ expect(onUsePlugin).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'user-plugin',
+ title: 'User Plugin',
+ }));
+ expect(mockedApplyPlugin).not.toHaveBeenCalled();
+ });
+
it('installs from a supported source string', async () => {
render();