mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 06:34:10 +08:00
feat(plugin-use): implement plugin use handoff functionality
- Added support for using installed plugins directly from the PluginsView, allowing users to initiate plugin actions seamlessly. - Enhanced the HomeView to handle plugin use handoffs, managing state and user interactions effectively. - Introduced new types and functions to facilitate the creation and processing of plugin use handoffs, improving the overall user experience. - Updated tests to cover the new plugin use functionality, ensuring reliability and correctness in the application flow. This update significantly enhances the interaction model for plugins, enabling users to utilize plugins more intuitively within the application.
This commit is contained in:
@@ -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' ? (
|
||||
<PluginsView
|
||||
onCreatePlugin={startPluginAuthoring}
|
||||
onUsePlugin={usePluginFromLibrary}
|
||||
onCreatePluginShareProject={onCreatePluginShareProject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -68,6 +68,11 @@ interface PendingReplacement {
|
||||
confirm: () => void;
|
||||
}
|
||||
|
||||
interface PendingPluginUseHandoff {
|
||||
pluginId: string;
|
||||
inputs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [pendingAuthoringChipId, setPendingAuthoringChipId] = useState<string | null>(null);
|
||||
const [pendingAuthoringPrompt, setPendingAuthoringPrompt] = useState(PLUGIN_AUTHORING_PROMPT);
|
||||
const [pendingPluginUseHandoff, setPendingPluginUseHandoff] =
|
||||
useState<PendingPluginUseHandoff | null>(null);
|
||||
const [fallbackProjectKind, setFallbackProjectKind] = useState<ProjectKind | null>(null);
|
||||
const [active, setActive] = useState<ActivePlugin | null>(null);
|
||||
const [activeSkill, setActiveSkill] = useState<SkillSummary | null>(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;
|
||||
|
||||
@@ -86,6 +86,7 @@ const PLUGIN_SHARE_DETAILS: Record<PluginShareAction, {
|
||||
|
||||
interface PluginsViewProps {
|
||||
onCreatePlugin?: (goal?: string) => 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 });
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export const PLUGIN_AUTHORING_PROMPT = [
|
||||
'Create an Open Design plugin for: <describe the workflow you want to package>.',
|
||||
@@ -44,3 +52,17 @@ export function createPluginAuthoringHandoff(
|
||||
source: 'plugin-authoring',
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginUseHandoff(
|
||||
id: number,
|
||||
pluginId: string,
|
||||
inputs?: Record<string, unknown>,
|
||||
): HomePromptHandoff {
|
||||
return {
|
||||
id,
|
||||
pluginId,
|
||||
...(inputs ? { inputs } : {}),
|
||||
focus: true,
|
||||
source: 'plugin-use',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof fetch>(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(
|
||||
<HomeView
|
||||
projects={[]}
|
||||
onSubmit={() => 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<typeof fetch>(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<typeof fetch>(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(
|
||||
<HomeView
|
||||
projects={[]}
|
||||
onSubmit={() => undefined}
|
||||
onOpenProject={() => undefined}
|
||||
onViewAllProjects={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = await screen.findByTestId('home-hero-input');
|
||||
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
|
||||
|
||||
rerender(
|
||||
<HomeView
|
||||
projects={[]}
|
||||
onSubmit={() => 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<typeof fetch>(async (url) => {
|
||||
if (typeof url === 'string' && url === '/api/plugins') {
|
||||
|
||||
@@ -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(<PluginsView onUsePlugin={onUsePlugin} />);
|
||||
|
||||
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(<PluginsView />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user