fix(web): resolve skill vs type-chip routing conflicts (#2972) (#3031)

Clear the opposing selection when the user picks a skill or scenario
chip, and omit skillId when a scenario plugin is active on submit.
This commit is contained in:
吴杨帆 2026-05-27 12:37:21 +08:00 committed by GitHub
parent 5b22143763
commit 19142b0d11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 149 additions and 2 deletions

View file

@ -579,6 +579,7 @@ export function HomeView({
) {
const applyRequestId = activePluginApplyRequestRef.current + 1;
activePluginApplyRequestRef.current = applyRequestId;
setActiveSkill(null);
const shouldResolveImmediately = options?.deferApply !== true;
const inputFields = options?.inputFields ?? record.manifest?.od?.inputs ?? [];
const optimisticInputs = hydratePluginInputs(
@ -992,9 +993,13 @@ export function HomeView({
}
function useSkill(skill: SkillSummary, nextPrompt: string | null) {
setActiveSkill(skill);
activePluginApplyRequestRef.current += 1;
setActive(null);
setPendingChipId(null);
setPendingApplyId(null);
setFallbackProjectKind(null);
setFallbackProjectMetadata(null);
setActiveSkill(skill);
setError(null);
const replacement = nextPrompt ?? localizeSkillPrompt(locale, skill) ?? '';
if (replacement.trim().length > 0) {
@ -1248,10 +1253,13 @@ export function HomeView({
submittedActive?.inputs ?? null,
submittedActive?.projectMetadata ?? fallbackProjectMetadata ?? null,
);
// Scenario plugins (chips / preset cards) and explicit skill picks are
// mutually exclusive routing sources — never send both (#2972).
const resolvedSkillId = submittedActive ? null : activeSkill?.id ?? null;
onSubmit({
prompt: trimmed,
pluginId: submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
skillId: activeSkill?.id ?? null,
skillId: resolvedSkillId,
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
pluginTitle: submittedActive?.record.title ?? null,
taskKind: submittedActive?.result?.appliedPlugin?.taskKind ?? null,

View file

@ -24,6 +24,18 @@ const SKILL: SkillSummary = {
aggregatesExamples: false,
};
const DECK_SKILL: SkillSummary = {
...SKILL,
id: 'deck-lab',
name: 'Deck Lab',
description: 'Create a focused slide deck.',
triggers: ['deck', 'slides'],
mode: 'deck',
examplePrompt: 'Design a focused investor deck.',
};
const WEB_PROTOTYPE_PLUGIN = makePlugin('example-web-prototype', 'Web Prototype');
function makePlugin(id: string, title: string): InstalledPluginRecord {
return {
id,
@ -234,4 +246,131 @@ describe('HomeView context picker', () => {
projectKind: 'prototype',
}));
});
it('clears an active type chip when the user picks a skill (#2972)', 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 === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
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 onSubmit = vi.fn();
render(
<HomeView
projects={[]}
skills={[DECK_SKILL, SKILL]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype');
});
const input = screen.getByTestId('home-hero-input');
fireEvent.change(input, { target: { value: '@deck' } });
fireEvent.mouseDown(screen.getByRole('option', { name: /deck lab/i }));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy();
expect(screen.queryByTestId('home-hero-active-type-chip')).toBeNull();
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
skillId: DECK_SKILL.id,
projectKind: 'deck',
}));
expect(onSubmit.mock.calls[0]?.[0]?.pluginId).not.toBe('example-web-prototype');
});
it('clears an active skill when the user picks a type chip (#2972)', 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({
appliedPlugin: {
snapshotId: 'snap-web-prototype',
pluginId: 'example-web-prototype',
pluginVersion: '1.0.0',
inputs: {},
},
contextItems: [],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
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 onSubmit = vi.fn();
render(
<HomeView
projects={[]}
skills={[SKILL]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: '@proto' } });
fireEvent.mouseDown(screen.getByRole('option', { name: /prototype lab/i }));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy();
});
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Prototype');
expect(screen.queryByTestId('home-hero-active-skill')).toBeNull();
});
fireEvent.change(input, { target: { value: 'Build a pricing-page prototype.' } });
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-web-prototype',
skillId: null,
projectKind: 'prototype',
})));
});
});