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();