fix(web): scope Use actions to the plugin detail modal and decouple the Community filter from hero chips (#4037)

* fix(web): remove inline Use actions from Community gallery tiles

The Home Community gallery tile grew an inline Use / Use with query
split button in its title bar, repeating per card. The gallery tile is
a pure live preview: opening the detail modal is where Use and Use
with query live. Drop the gallery use menu (and its CSS) and route the
visual-home use-with-query e2e flow through the detail modal's split
Use button. The rich management layout (PluginsView installed tab)
keeps its hover-overlay Use menu.

* fix(web): decouple the Community category filter from hero type chips

Picking an example-prompt type chip (Prototype / Slide deck / ...) —
including the default chip on first paint — force-synced the Community
grid's category selection through presetSelection, silently rewriting
the user's browsing filter. The two surfaces share a taxonomy but are
independent controls: the chip drives what the composer generates, the
pills only filter the gallery. Remove the presetSelection plumbing
(HomeView memo -> PluginsHomeSection prop -> usePluginFacets effect);
the Community grid now boots on All and only moves when its own pills
are clicked.

* fix(e2e): match the Deck Writer detail dialog by plugin name

Deck Writer carries a previewEntry, so its detail surface is the
PreviewModal whose dialog is labelled 'Deck Writer preview' — the
'... details' label only exists on the scenario detail. Match the
dialog on the plugin name so the visual use-with-query flow finds it.

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
elihahah666
2026-06-10 06:54:30 +00:00
committed by GitHub
co-authored by qiongyu1999
parent b057200405
commit 844a0c0af1
8 changed files with 129 additions and 187 deletions
-37
View File
@@ -81,7 +81,6 @@ import { PluginDetailsModal } from './PluginDetailsModal';
import { HomeTemplatesReveal } from './HomeTemplatesReveal';
import { PluginsHomeSection } from './PluginsHomeSection';
import type { PluginLoopSubmit } from './PluginLoopHome';
import type { FacetSelection } from './plugins-home/facets';
import { localizePluginTitle } from './plugins-home/localization';
import type { PluginUseAction } from './plugins-home/useActions';
import { examplePresetSeedPrompt } from './plugins-home/presetSeedPrompt';
@@ -577,20 +576,6 @@ export function HomeView({
stagedFiles.length,
]);
// The Home chip rail and the Community grid share a mental
// model — "Prototype" up top is the same artifact intent as the
// `prototype` slice down below. When the user picks a chip,
// we drive the starters' FacetSelection from it so they get a
// pre-filtered shelf of templates for the same intent without having
// to scroll and re-pick. `pendingChipId` (set on click, before apply
// resolves) is preferred over `active?.chipId` so the filter snaps on
// the same frame as the click.
const presetStartersSelection = useMemo<FacetSelection | null>(() => {
const chipId = pendingChipId ?? active?.chipId ?? null;
if (!chipId) return null;
return facetSelectionForChip(chipId);
}, [pendingChipId, active?.chipId]);
// When the active plugin was bound through a chip, the badge shows
// the chip label (e.g. "Prototype") instead of the underlying plugin
// record title (e.g. "New generation (default scenario)"). Several
@@ -1681,7 +1666,6 @@ export function HomeView({
onOpenExternal={handleCommunityOpenExternal}
onBrowseRegistry={onBrowseRegistry}
preferDefaultFacet={false}
presetSelection={presetStartersSelection}
cardLayout="gallery"
/>
</HomeTemplatesReveal>
@@ -1812,27 +1796,6 @@ export function shouldShowActivePluginChip(active: ActivePlugin | null): boolean
return active.record.id !== defaultPluginIdForChip(active.chipId);
}
// Maps a Home hero chip id to the Community facet slice the
// user most likely wants to browse next. The chip rail is intent
// ("I want to design a slide deck"); the starters grid is the catalog
// for that intent, so pinning the same `deck` slice lets the
// user keep scanning examples without re-picking the same artifact
// kind in a different control. The list mirrors the `apply-scenario`
// and `apply-figma-migration` chip ids in `home-hero/chips.ts`; any
// new chip there should add a row here too.
function facetSelectionForChip(chipId: string): FacetSelection | null {
switch (chipId) {
case 'prototype': return { category: 'prototype', subcategory: null };
case 'live-artifact': return { category: 'live-artifact', subcategory: null };
case 'deck': return { category: 'deck', subcategory: null };
case 'image': return { category: 'image', subcategory: null };
case 'video': return { category: 'video', subcategory: null };
case 'hyperframes': return { category: 'hyperframes', subcategory: null };
case 'audio': return { category: 'audio', subcategory: null };
default: return null;
}
}
function homeHeroChipLabelForId(chipId: string, t: ReturnType<typeof useI18n>['t']): string {
switch (chipId) {
case 'prototype': return t('homeHero.chip.prototype');
@@ -20,7 +20,7 @@ import { useI18n, useT } from '../i18n';
import type { PluginShareAction } from '../state/projects';
import { Icon } from './Icon';
import { PluginCard } from './plugins-home/PluginCard';
import { isFeaturedPlugin, type FacetOption, type FacetSelection } from './plugins-home/facets';
import { isFeaturedPlugin, type FacetOption } from './plugins-home/facets';
import { localizePluginTitle } from './plugins-home/localization';
import { usePluginFacets } from './plugins-home/usePluginFacets';
import { useSavedPluginIds } from './plugins-home/savedPlugins';
@@ -47,12 +47,6 @@ interface Props {
) => void;
onBrowseRegistry?: () => void;
preferDefaultFacet?: boolean;
// Optional external selection. When the Home chip rail picks
// "Slide deck", HomeView passes { category: 'deck', subcategory:
// null } so the Community grid scrolls to the matching
// slice instead of staying on its default. The hook only re-applies
// when this identity changes, so manual facet clicks still win.
presetSelection?: FacetSelection | null;
title?: string;
subtitle?: string;
emptyMessage?: string;
@@ -73,7 +67,6 @@ export function PluginsHomeSection({
onPluginShareAction,
onBrowseRegistry,
preferDefaultFacet = true,
presetSelection = null,
title,
subtitle,
emptyMessage,
@@ -102,7 +95,6 @@ export function PluginsHomeSection({
plugins,
savedPluginIds,
preferDefaultFacet,
presetSelection,
locale,
});
const renderedPlugins = useMemo(
@@ -136,71 +136,6 @@ export function PluginCard({
>
{title}
</button>
<div
className={`plugins-home__gallery-use plugins-home__use-menu${hasQuery ? ' has-options' : ''}`}
onClick={(event) => event.stopPropagation()}
onBlur={(event) => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setUseMenuOpen(false);
}
}}
>
<button
type="button"
className="plugins-home__gallery-use-main"
onClick={() => pickUseAction('use')}
disabled={useDisabled}
aria-busy={isPending ? 'true' : undefined}
data-testid={`plugins-home-use-${record.id}`}
>
{isPending ? 'Applying...' : 'Use'}
</button>
{hasQuery ? (
<>
<button
type="button"
className="plugins-home__gallery-use-more"
onClick={() => setUseMenuOpen((open) => !open)}
disabled={useDisabled}
aria-haspopup="menu"
aria-expanded={useMenuOpen}
aria-label={`Choose how to use ${title}`}
data-testid={`plugins-home-use-menu-${record.id}`}
>
<Icon name="chevron-down" size={12} />
</button>
{useMenuOpen ? (
<div
className="plugins-home__use-menu-list plugins-home__gallery-use-list"
role="menu"
aria-label={`Use options for ${title}`}
>
<button
type="button"
role="menuitem"
className="plugins-home__use-menu-item"
onMouseDown={(event) => event.preventDefault()}
onClick={() => pickUseAction('use')}
data-testid={`plugins-home-use-context-${record.id}`}
>
Use
</button>
<button
type="button"
role="menuitem"
className="plugins-home__use-menu-item"
onMouseDown={(event) => event.preventDefault()}
onClick={() => pickUseAction('use-with-query')}
data-testid={`plugins-home-use-with-query-${record.id}`}
>
Use with query
</button>
</div>
) : null}
</>
) : null}
</div>
{previewSrc ? (
<a
className="plugins-home__gallery-open"
@@ -11,7 +11,7 @@
// override rather than AND-compose so a saved pick is never
// accidentally hidden behind a still-selected category pill.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
applyFacetSelection,
@@ -29,13 +29,6 @@ interface UsePluginFacetsArgs {
plugins: InstalledPluginRecord[];
savedPluginIds?: ReadonlySet<string>;
preferDefaultFacet?: boolean;
// External selection driven by the Home hero chip rail. When this
// value changes to a new (non-null) selection, the hook applies it,
// overriding both the user's manual pick and the default-facet
// bootstrap. We track the last-applied identity so the user can
// still click a different category afterwards without the effect
// snapping back on every re-render.
presetSelection?: FacetSelection | null;
locale?: string;
}
@@ -65,7 +58,6 @@ export function usePluginFacets({
plugins,
savedPluginIds,
preferDefaultFacet = true,
presetSelection = null,
locale,
}: UsePluginFacetsArgs): UsePluginFacetsResult {
const [mode, setMode] = useState<FilterMode>('all');
@@ -76,7 +68,6 @@ export function usePluginFacets({
// initializer) handles the realistic case where `args.plugins` is
// empty at first paint and arrives a tick later.
const [bootstrapped, setBootstrapped] = useState(false);
const lastAppliedPresetKeyRef = useRef<string | null>(null);
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
// are not user-facing on the home grid; the original section already
@@ -114,22 +105,6 @@ export function usePluginFacets({
setBootstrapped(true);
}, [bootstrapped, preferDefaultFacet, visiblePlugins.length, catalog]);
// Sync an externally-driven selection (the Home chip rail) into the
// facet state. We only apply a preset once per identity so the user
// can still click a different facet pill afterwards without the
// effect snapping back. Setting `bootstrapped` here also prevents
// the default-facet effect above from overriding the preset on the
// first non-empty render.
useEffect(() => {
if (!presetSelection) return;
const key = `${presetSelection.category ?? ''}::${presetSelection.subcategory ?? ''}`;
if (lastAppliedPresetKeyRef.current === key) return;
lastAppliedPresetKeyRef.current = key;
setSelection(presetSelection);
setMode((current) => (current === 'saved' ? 'all' : current));
setBootstrapped(true);
}, [presetSelection]);
// The visual-appeal sort is applied at `visiblePlugins` derivation
// (above), so any downstream `applyFacetSelection` slice preserves
// the ranking. We do not re-sort here because filter + featured
-47
View File
@@ -500,53 +500,6 @@
outline-offset: 2px;
border-radius: 4px;
}
.plugins-home__gallery-use {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
height: 22px;
}
.plugins-home__gallery-use-main,
.plugins-home__gallery-use-more {
appearance: none;
border: 1px solid var(--border-strong);
background: var(--bg-panel);
color: var(--text);
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.plugins-home__gallery-use-main {
border-radius: 6px 0 0 6px;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
}
.plugins-home__gallery-use-more {
border-left: 0;
border-radius: 0 6px 6px 0;
width: 22px;
color: var(--text-muted);
}
.plugins-home__gallery-use-main:hover,
.plugins-home__gallery-use-more:hover {
background: var(--bg);
color: var(--accent);
}
.plugins-home__gallery-use-main:disabled,
.plugins-home__gallery-use-more:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.plugins-home__gallery-use-list {
top: calc(100% + 6px);
right: 0;
left: auto;
z-index: 5;
}
.plugins-home__gallery-open {
flex: 0 0 auto;
display: inline-flex;
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
// Red spec for the example-chip ↔ Community filter decoupling.
//
// The hero chip rail (Prototype / Slide deck / ...) and the Community
// grid expose the same artifact taxonomy, but they are independent
// surfaces: picking a chip drives what the composer will generate,
// while the Community pills only filter the gallery the user is
// browsing. Binding them means any chip interaction (including the
// default active chip on first paint) silently rewrites the user's
// browsing filter — so the gallery must stay on its own selection.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { HomeView } from '../../src/components/HomeView';
import { I18nProvider } from '../../src/i18n';
function makeHomePlugin(id: string, mode: string) {
return {
id,
title: id,
version: '1.0.0',
trust: 'bundled' as const,
sourceKind: 'bundled' as const,
source: `/tmp/${id}`,
capabilitiesGranted: ['prompt:inject'],
fsPath: `/tmp/${id}`,
installedAt: 0,
updatedAt: 0,
manifest: {
name: id,
title: id,
version: '1.0.0',
description: `${id} fixture`,
od: { kind: 'scenario', taskKind: 'new-generation', mode },
},
};
}
const PLUGINS = [
makeHomePlugin('example-web-prototype', 'prototype'),
makeHomePlugin('example-simple-deck', 'deck'),
];
function ariaSelected(testId: string): string | null {
return screen.getByTestId(testId).getAttribute('aria-selected');
}
describe('HomeView community filter decoupling', () => {
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('keeps the Community category selection independent from the hero type chips', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: PLUGINS }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
render(
<I18nProvider initial="en">
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>
</I18nProvider>,
);
// Home boots with a default active type chip; the Community grid must
// still come up unfiltered ("All"), not pre-snapped to that chip.
await waitFor(() => {
expect(screen.getByTestId('plugins-home-pill-category-deck')).toBeTruthy();
});
expect(ariaSelected('plugins-home-pill-category-all')).toBe('true');
expect(ariaSelected('plugins-home-pill-category-prototype')).toBe('false');
// Picking another chip drives the composer, not the gallery filter.
fireEvent.click(await screen.findByTestId('home-hero-rail-deck'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Slide deck');
});
expect(ariaSelected('plugins-home-pill-category-all')).toBe('true');
expect(ariaSelected('plugins-home-pill-category-deck')).toBe('false');
// And the gallery's own pills still work locally.
fireEvent.click(screen.getByTestId('plugins-home-pill-category-deck'));
expect(ariaSelected('plugins-home-pill-category-deck')).toBe('true');
});
});
@@ -133,6 +133,26 @@ const sample: InstalledPluginRecord[] = [
makePlugin({ id: 'hidden-atom', mode: 'prototype', tags: ['dashboard'], kind: 'atom' }),
];
describe('PluginsHomeSection (community gallery)', () => {
it('keeps gallery tiles free of inline Use actions — Use lives in the detail modal', () => {
renderSection(sample, { cardLayout: 'gallery' });
// The tile itself stays a pure preview: name button opens details,
// ↗ opens the example page. Use / Use with query are detail-modal
// affordances only.
expect(screen.getByTestId('plugins-home-details-prototype-dashboard')).toBeTruthy();
expect(screen.queryByTestId('plugins-home-use-prototype-dashboard')).toBeNull();
expect(screen.queryByTestId('plugins-home-use-menu-prototype-dashboard')).toBeNull();
expect(screen.queryByTestId('plugins-home-use-with-query-prototype-dashboard')).toBeNull();
});
it('keeps the inline Use menu on the rich management layout (PluginsView)', () => {
renderSection(sample, { cardLayout: 'rich' });
expect(screen.getByTestId('plugins-home-use-prototype-dashboard')).toBeTruthy();
});
});
describe('PluginsHomeSection (category bar)', () => {
it('frames the home shelf as community and can jump to registry', () => {
const onBrowseRegistry = vi.fn();
+9 -3
View File
@@ -198,9 +198,15 @@ test('[P2] captures the home plugin use with query surface', async ({ page }) =>
await home.getByTestId('plugins-home-pill-category-deck').click();
const card = home.locator('article.plugins-home__card[data-plugin-id="visual-deck-writer"]');
await expect(card).toBeVisible();
await card.hover();
await home.getByTestId('plugins-home-use-menu-visual-deck-writer').click({ force: true });
await home.getByTestId('plugins-home-use-with-query-visual-deck-writer').click();
// Community gallery tiles carry no inline Use actions — use-with-query
// lives behind the detail modal's split Use button.
await home.getByTestId('plugins-home-details-visual-deck-writer').click({ force: true });
// Deck Writer ships a previewEntry, so its detail surface is the
// PreviewModal (aria-label "Deck Writer preview"), not the scenario
// detail's "... details" dialog. Match on the plugin name only.
await expect(page.getByRole('dialog', { name: /Deck Writer/i })).toBeVisible();
await page.getByTestId('plugin-details-use-visual-deck-writer-menu').click();
await page.getByTestId('plugin-details-use-with-query-visual-deck-writer').click();
// use-with-query now seeds the rendered preset text (placeholders filled in),
// not the raw `{{...}}` query — matching the example-prompt card path.
await expect(page.getByTestId('home-hero-input')).toContainText('Draft a topic deck.');