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:
pftom
2026-05-14 15:40:42 +08:00
parent 9ea33e076b
commit 1a90aef4a2
6 changed files with 193 additions and 11 deletions

View File

@@ -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}

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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',
};
}

View File

@@ -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') {

View File

@@ -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 />);