open-design/apps/web/tests/components/HomeHero.plugin-picker.test.tsx
chaoxiaoche a38e09f931
fix(web): demote Plugins and Integrations to nav rail footer (#1806)
* fix(web): demote Plugins and Integrations to nav rail footer

Plugins and Integrations are platform-configuration surfaces, not
daily-use destinations. Moving them to the footer section of the
left nav rail — separated from primary items by a thin divider —
keeps them reachable while giving the primary four items
(Home, Projects, Automations, Design Systems) the visual weight
they deserve.

- EntryNavRail: remove Plugins/Integrations from the main __group
  and place them in the __footer above the help launcher
- entry-layout.css: add __divider rule (1 px separator) to visually
  mark the boundary between primary and secondary nav regions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): remove settings dropdown, gear opens settings directly

The gear/cog button previously opened a dropdown that mixed three
unrelated concerns: community links (X, Discord), preference quick-
access (Language, Appearance), a feature shortcut (Use everywhere),
and a redundant Settings entry — creating two separate paths to the
same Settings dialog and duplicating Language/Appearance relative to
the Settings sidebar.

Changes:
- Gear button now directly opens the Settings dialog (no intermediate
  dropdown layer)
- Follow @nexudotio on X and Join Discord moved to the Help menu at
  the bottom of the nav rail, where community/external links belong
- Language and Appearance remain exclusively in the Settings dialog
- Use everywhere remains exclusively in the topbar chip
- Remove dead state (avatarMenuOpen, languageExpanded,
  appearanceExpanded), ref, outside-click effect, and module-level
  constants (APPEARANCE_THEMES, APPEARANCE_LABEL, describeModelChip)
  that were introduced solely to support the dropdown

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): move output-type chips above chat input as tab bar

The home hero previously had two chip rows below the chat input that
mixed two unrelated dimensions: output type (Prototype, Slide deck,
Image…) and workflow source (Create plugin, From Figma, From folder,
From template). Users had no visual cue that these were different
categories.

This change separates the two dimensions clearly:

- Output type (create group) becomes a tab bar positioned above the
  input card. Tabs share the same chip data and onPickChip dispatch,
  so plugin selection, active state, and pending state are unchanged.
  Active tab shows a colored underline; the bar border visually
  connects to the input card below.

- Workflow source (migrate group) stays as the chip row below the
  input card, now standing alone with unambiguous "how to start"
  semantics.

- Subtitle updated from "Pick a plugin below" to "Pick a type"
  to match the new placement.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): skip replace-prompt confirmation when switching output-type tabs

Output-type tab clicks (create group) are mode-selection gestures;
the user expects the prompt to update immediately without a dialog.
The confirmation is still shown for migrate-group chips (From Figma
etc.) where the replacement carries meaningful user-provided content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): use brand logo as Home destination, drop redundant brand mentions

The entry nav rail previously rendered the brand logo and a separate
Home icon back-to-back; both invoked `onViewChange('home')`, so the
Home button was pure duplicate affordance. The hero pane also
displayed a third "Open Design" lockup that competed with the rail
logo and the rebranded title.

This collapses those affordances:

* `EntryNavRail` drops the dedicated Home `NavButton`. The brand
  logo button now carries `aria-current="page"` and an `is-active`
  visual state when the home view is showing, and its tooltip reads
  "Open Design · Home" off-home so the navigation behavior stays
  discoverable for new users.
* `entry-layout.css` adds the matching `.entry-nav-rail__logo.is-active`
  accent treatment so the "you are here" cue reads at parity with
  primary rail buttons.
* `HomeHero` removes the inline `home-hero__brand` lockup and the
  associated CSS, then retunes the title/subtitle/type-tab spacing
  so the headline group still pairs tightly with the type tabs and
  input card below.
* `entry-chrome-flows` is updated to assert the logo carries the
  active page treatment and that no `entry-nav-home` test id
  resurfaces by mistake.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): auto-grow home chat input, disable internal scroll and drag-resize

The home hero textarea was capped at a fixed `min-height: 84px` with
`resize: vertical`, so users had to either drag the bottom-right
corner to enlarge it or scroll the textarea internally to read
longer prompts. That hid context (loaded plugin templates routinely
overflow three lines) and exposed a manual grip whose state was easy
to leave in an awkward height.

This change makes the chat box grow with its content:

* `HomeHero` adds a `useLayoutEffect` that, on every prompt change,
  resets the textarea height to `auto` so the browser can measure a
  smaller content, then writes back `scrollHeight` in pixels. The
  effect uses layout phase (not effect phase) to avoid a one-frame
  flash at the previous height when a plugin loads a long example
  prompt.
* `home-hero.css` swaps `resize: vertical` for `resize: none` and
  adds `overflow: hidden`, so the manual drag grip disappears and
  the textarea never scrolls internally. `min-height: 84px` is kept
  so the empty input still reads as a chunky chat box. The outer
  page handles overflow when the prompt is genuinely very long.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): make output-type tabs read as folder-tabs attached to chat box

The previous tab bar used a small icon + label per tab and signaled
the active tab with a 2px accent underline sitting on a horizontal
divider line. That divider visually broke the relationship between
the tabs and the input card below — the active tab looked like a
free-floating header, not a flap of the chat surface, so users had
to do extra work to mentally connect "I picked Prototype" with the
prompt area immediately underneath.

This switches the tab bar to a folder-tab pattern:

* `HomeHero` drops the per-tab `Icon` element. The seven labels
  (Prototype, Live artifact, Slide deck, Image, Video, HyperFrames,
  Audio) already disambiguate at the type sizes used here, and the
  icons were primarily decorative.
* `home-hero.css` rewrites the tab styling:
  - The bar no longer paints its own baseline border; the input
    card's top edge serves as the baseline.
  - Each tab is a rounded-top container with a 1px transparent
    border and a `-1px` bottom margin, so its bottom edge overlaps
    the card's top border by exactly one pixel.
  - The active tab borrows the card's panel background and border
    color, and overrides its bottom border with the panel color
    so it visually erases the card's top border for the tab's
    width. The result reads as one continuous "Prototype is this
    chat box" surface.
  - The card's prior `margin-top: 8px` is removed so the tab bar
    bottom and card top sit at the same y coordinate, which is the
    geometric precondition for the 1px overlap to land cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): enlarge home chat attach and submit buttons for legibility

The attach (paper-clip) and submit (arrow-up) controls on the home
chat input rendered at 32px diameter with 14px and 18px glyphs
respectively. After stroke antialiasing the icons read at roughly
11–12px on typical displays — small enough that users reported the
glyphs were illegible and had to be discovered by trial-and-error.

This bumps both controls to 38px circles and grows their glyphs
(attach 14 → 18, submit 18 → 22). The primary call-to-action now
has clearly more visual weight than the surrounding muted hint
text, and the paper-clip is recognisable at a glance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): paint a chevron on inline select slots in the home prompt

Inline select slots in the home prompt overlay (e.g. the
`high-fidelity` / `wireframe` fidelity picker on the Live artifact
template) rendered as plain pill highlights, visually identical to
free-text and read-only text slots. The slot's `appearance: none`
strips the browser's default chevron, so users had no affordance
hinting the value was switchable.

This wraps select-type slots in an `inline-flex` span and overlays
an explicit chevron-down `Icon` against the slot's trailing padding
(bumped from 18px to 22px to make room). The chevron inherits the
accent color via the wrap's `color` property so it themes correctly
in light and dark modes. Click semantics are unchanged because the
chevron is `pointer-events: none`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): size inline number slots to their value, not the whole row

Number-type inline slots in the home prompt (e.g. the slide-count
spinner on the Slide deck template) inherited the generic slot
`min-width: 8ch` and then stretched to the browser's default
`<input type=number>` width, so a two-digit value like "10" ate
the entire remaining line and pushed the native spinner buttons
to the far right edge — far away from the value they control.

This sizes the number input to its actual content plus four
characters of trailing room for the spinner buttons (clamped to
6–14ch), and adds a `home-hero__prompt-slot-input--number`
modifier that overrides the slot `min-width` to 4ch. The
spinner now sits flush against the value and the slot reads as
a compact inline pill matching the surrounding text/select
slots.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): widen prompt line-height so slot pills do not collide vertically

Each interactive slot and mention in the home prompt paints a 2px
outline ring via `box-shadow`. At the previous `line-height: 1.55`
on a 15px font, two lines were ~23px apart while a single pill
occupied ~20px of vertical space (text box + ring on both sides),
leaving roughly 2px of clear space between rows. When the prompt
wrapped onto multiple lines — common for the Image template's
"Generate a {kind} of {subject}. Style: {style}. Aspect: {ratio}."
example — the rings from line N and line N+1 visually merged into
a single bar, making it ambiguous which pill the user was about to
click or edit.

This bumps the line-height of both the highlight overlay and the
underlying editable textarea to 1.85 (~28px per line), restoring
~8px of clear space between pill rows. The two values must stay
identical so the overlay glyphs continue to track the textarea
caret positions exactly; a brief comment in each rule documents
this coupling for future edits.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): paint dropdown chevron on select element with neutral color

The previous chevron treatment wrapped each select slot in an
inline-flex span and laid an accent-orange `<Icon>` over the
trailing padding. At the prompt's normal display size the orange
chevron blended into the orange pill ring corners, so users
perceived "a bunch of dropdown arrows" across every highlighted
slot, even though only the actual select rendered one.

Move the chevron onto the `<select>` itself as a small neutral-
gray `background-image` SVG. The grey contrasts clearly with the
pill's accent ring and the chevron lives inside the select's own
padding, so it can never visually overlap the value text and can
never appear on non-select slots.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): shrink select-slot dropdown caret so it stops reading as a check-mark

The 8×6 stroked chevron looked like a check-mark in zoomed views
because the stroke width was a large fraction of the glyph. Swap
for a small (9×5) filled triangle drawn as a `background-image`,
with `background-size` pinned so browsers can't scale it to its
intrinsic SVG box. The caret is now unambiguously a dropdown
indicator without crowding the value text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): make read-only prompt slots visually distinct from editable pills

Multi-word context values are rendered as plain <span>s with
pointer-events: none, but they inherited the same orange pill +
ring treatment as the truly editable <input>/<select> slots.
Users couldn't tell which pills they could click into.

Strip the pill background, the ring, the radius, and the padding
from `.home-hero__prompt-slot-text` and replace them with a
subtle dashed bottom border. The orange foreground keeps the
slot family link, but read-only highlights now clearly read as
"context value spliced into the prompt" rather than as
"interactive control".

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): scale select-slot dropdown caret up to 12px wide for legibility

The 9×5 caret was too quiet at the prompt's font size to read as
a dropdown affordance. Bump to a 12×6 filled triangle and pad the
select's trailing space (24px) so the value text still has clear
breathing room from the caret.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): collapse plugins search and filter strips into one bar

The plugins-home gallery laid out search, count, mode (Featured),
total-in-catalog, clear-filters, the main category row, and the
sub-category row as four floating clusters. Search lived up in
the section header, far from the chips it actually scopes; the
mode strip wedged "Featured + 386 in catalog + Clear filters"
between the header and the category row with no obvious
ownership; and the two clear-filter affordances duplicated each
other (chip strip + empty-state).

Fold the mode strip into the category row: Featured is now the
leading chip on the same line as the category pills, and the
search field, the result count, and a compact Clear link sit as
a right-aligned tools cluster on that same row. The header
shrinks back to just the title, subtitle, and Browse registry
link. The sub-category row stays as the contextual second line
when a category is active.

Tests: keeps the existing data-testids (plugins-home-chip-featured,
plugins-home-row-category, plugins-home-clear, plugins-home-count,
plugins-home-search) so the existing 11-case section spec passes
without modification.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): hide Recent projects rail on first-run home when empty

A brand-new user landing on Home saw an empty dashed box with the
copy "No projects yet — type a prompt to start one." sitting
above the plugin gallery. The hero already prompts the user to
type, so the empty rail just adds vertical noise without telling
them anything new.

Return null from RecentProjectsStrip when the recent list is
empty (loading or not) so the rail appears only when there is
actually something to show. Update the entry-chrome e2e to
assert toHaveCount(0) on first run — codifying the new contract
that the rail is conditional, not always-mounted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): hide structured inputs form when every field is in the prompt

The PluginInputsForm below the chat textarea surfaced every plugin
input — even the ones the prompt template already substitutes
inline as highlighted slot pills. For a plugin like Prototype
that's five identical labelled inputs duplicating the five slot
pills above them, making the chat box look like it has grown a
second composer.

Compute the set of placeholder keys actually referenced in the
prompt template (via INPUT_PLACEHOLDER_PATTERN) and skip those
when deciding what the structured form needs to render. The form
still appears for plugins that expose inputs not referenced in
the prompt text (e.g. a "Run in background" toggle), but
template-only plugins collapse the redundant second editor.

Update the picker spec accordingly: when every field is in the
template the form is now expected to be absent rather than to
render a duplicate of the inline slot.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): drop redundant filtered-count and Clear link beside the search

The combined plugins filter bar trailed the search field with
"59 / 386" and an inline "Clear" link, but both repeat what the
chip strip already shows: every chip carries its own count
(Slides 59, All 386, …) and clicking the All chip already resets
the filters. Users had no way to know what the bare "59 / 386"
fraction or "Clear" referred to without inference.

Strip the trailing tools cluster down to just the search input.
The empty-results message at the bottom of the gallery still
exposes a contextual "Clear filters" button when a stacked
filter yields no matches, so the affordance isn't lost — just
removed from a position where it didn't read as actionable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): drop default subtitle copy from the Official starters strip

The Home gallery rendered a two-line explanatory subtitle under
"Official starters" — "Ready-to-use Open Design workflows bundled
with this runtime. Pick one to load a starter prompt, or browse
the registry for more." — every visit. Returning users skip it,
new users get the same message from the section title plus the
Browse registry link plus the visual card grid itself; the prose
read as filler chrome above the chip strip.

Default the subtitle prop to undefined and only render the <p>
when a caller passes an explicit string. Other surfaces that
mount PluginsHomeSection with their own copy keep their
subtitle; the bare Home gallery loses one row of vertical noise.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fade out empty workflow lanes in Plugins home filter

Empty top-level lanes (Deploy 0, Refine 0, etc.) and empty
sub-categories (Vercel 0 under Deploy, Figma 0 under Import, etc.)
used to look identical to populated ones, so users couldn't tell at
a glance which chips were real catalog buckets and which were
"contribute a plugin" invites. The strip kept all lanes visible by
design — the workflow shape (Import / Create / Export / Share /
Deploy / Refine / Extend) is part of what we want users to see —
so we keep them clickable, but tag count-zero chips with
data-empty='true' and give them a faded, dashed-border treatment.
"All" pills stay solid since their count reflects the parent lane,
not their own emptiness.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): update home nav test expectations

* fix(e2e): align critical smoke with entry chrome

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:58:15 +08:00

396 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
InputFieldSpec,
InstalledPluginRecord,
McpServerConfig,
PluginSourceKind,
SkillSummary,
TrustTier,
} from '@open-design/contracts';
import { HomeHero } from '../../src/components/HomeHero';
function makePlugin(
id: string,
title: string,
sourceKind: PluginSourceKind = 'bundled',
trust: TrustTier = 'bundled',
): InstalledPluginRecord {
return {
id,
title,
version: '1.0.0',
sourceKind,
source: '/tmp',
trust,
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: id,
version: '1.0.0',
title,
description: 'A plugin fixture',
tags: ['fixture'],
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
function makeSkill(id: string, name: string): SkillSummary {
return {
id,
name,
description: 'A skill fixture',
triggers: ['fixture'],
mode: 'prototype',
previewType: 'html',
designSystemRequired: false,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: `Use ${name}`,
aggregatesExamples: false,
};
}
function makeMcp(id: string, label: string): McpServerConfig {
return {
id,
label,
transport: 'stdio',
enabled: true,
command: 'npx',
};
}
afterEach(() => {
cleanup();
});
describe('HomeHero plugin picker', () => {
it('opens plugin search from an @ token across community and my plugins', () => {
const onPromptChange = vi.fn();
const onPickPlugin = vi.fn();
render(
<HomeHero
prompt="Make @sam"
onPromptChange={onPromptChange}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[
makePlugin('sample-plugin', 'Sample Plugin'),
makePlugin('sample-user-plugin', 'Sample User Plugin', 'github', 'restricted'),
]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={onPickPlugin}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.getByTestId('home-hero-plugin-picker')).toBeTruthy();
expect(screen.getByText('Official')).toBeTruthy();
expect(screen.getByText('My plugin')).toBeTruthy();
fireEvent.mouseDown(screen.getByRole('option', { name: /sample user plugin/i }));
expect(onPickPlugin).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sample-user-plugin' }),
'Make @Sample User Plugin',
);
});
it('renders selected @ plugins inside the prompt and opens their details', () => {
const onOpenPluginDetails = vi.fn();
const sample = makePlugin('sample-plugin', 'Sample Plugin');
const helper = makePlugin('helper-plugin', 'Helper Plugin');
render(
<HomeHero
prompt="Use @Sample Plugin with @Helper Plugin"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
selectedPluginContexts={[sample, helper]}
onOpenPluginDetails={onOpenPluginDetails}
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={2}
error={null}
/>,
);
fireEvent.click(screen.getByTestId('home-hero-prompt-plugin-sample-plugin'));
expect(onOpenPluginDetails).toHaveBeenCalledWith(sample);
expect(screen.getByTestId('home-hero-prompt-plugin-helper-plugin')).toBeTruthy();
});
it('opens the context picker for a bare @ token even before results arrive', () => {
render(
<HomeHero
prompt="@"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
skillOptions={[]}
skillsLoading={false}
mcpOptions={[]}
mcpLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.getByTestId('home-hero-plugin-picker')).toBeTruthy();
expect(screen.getByRole('tab', { name: /plugins/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /skills/i })).toBeTruthy();
expect(screen.getByRole('tab', { name: /mcp/i })).toBeTruthy();
expect(screen.getByText('Search plugins, skills, and MCP servers.')).toBeTruthy();
});
it('can pick skills and MCP servers from the home @ picker', () => {
const onPickSkill = vi.fn();
const onPickMcp = vi.fn();
const skill = makeSkill('prototype-lab', 'Prototype Lab');
const mcp = makeMcp('linear', 'Linear');
const { rerender } = render(
<HomeHero
prompt="Make @proto"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
skillOptions={[skill]}
skillsLoading={false}
mcpOptions={[mcp]}
mcpLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickSkill={onPickSkill}
onPickMcp={onPickMcp}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
fireEvent.mouseDown(screen.getByRole('option', { name: /prototype lab/i }));
expect(onPickSkill).toHaveBeenCalledWith(skill, 'Make @Prototype Lab');
rerender(
<HomeHero
prompt="@lin"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
skillOptions={[skill]}
skillsLoading={false}
mcpOptions={[mcp]}
mcpLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickSkill={onPickSkill}
onPickMcp={onPickMcp}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
fireEvent.mouseDown(screen.getByRole('option', { name: /linear/i }));
expect(onPickMcp).toHaveBeenCalledWith(mcp, '@Linear');
});
it('does not submit while an IME composition is confirming text with Enter', () => {
const onSubmit = vi.fn();
render(
<HomeHero
prompt="做一个中文官网"
onPromptChange={() => undefined}
onSubmit={onSubmit}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
const input = screen.getByTestId('home-hero-input');
fireEvent.compositionStart(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.compositionEnd(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('does not pick a plugin while an IME composition is active', () => {
const onPickPlugin = vi.fn();
const onSubmit = vi.fn();
render(
<HomeHero
prompt="Make @sam"
onPromptChange={() => undefined}
onSubmit={onSubmit}
activePluginTitle={null}
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginOptions={[makePlugin('sample-plugin', 'Sample Plugin')]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={onPickPlugin}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
const input = screen.getByTestId('home-hero-input');
fireEvent.compositionStart(input);
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onPickPlugin).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
it('highlights rendered plugin input values inside the prompt surface', () => {
const fields: InputFieldSpec[] = [
{
name: 'source',
label: 'Import source',
type: 'select',
options: ['folder', 'zip', 'github', 'marketplace'],
default: 'marketplace',
},
];
const prompt =
'Create a compact import receipt for community-import-smoke-test installed from marketplace.';
const { rerender } = render(
<HomeHero
prompt={prompt}
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Community Import Smoke Test"
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginInputFields={fields}
pluginInputValues={{ source: 'marketplace' }}
pluginInputTemplate="Create a compact import receipt for community-import-smoke-test installed from {{source}}."
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
// The inline pill is a read-only span so its width tracks the
// textarea text exactly. (See HomeHero.tsx for why <input>/<select>
// at this position caused the overlay/textarea caret drift.)
const slot = screen.getByTestId('home-hero-prompt-slot-source');
expect(slot.tagName).toBe('SPAN');
expect(slot.textContent).toBe('marketplace');
expect(slot.getAttribute('data-filled')).toBe('true');
// The structured inputs form below the textarea is suppressed
// when every plugin input is already referenced in the template
// — otherwise the form would render a second, identical labelled
// input for every slot pill shown inline, making the chat box
// look like it had grown a second composer.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
rerender(
<HomeHero
prompt={`${prompt} Extra user edit.`}
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Community Import Smoke Test"
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginInputFields={fields}
pluginInputValues={{ source: 'marketplace' }}
pluginInputTemplate="Create a compact import receipt for community-import-smoke-test installed from {{source}}."
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.queryByTestId('home-hero-prompt-slot-source')).toBeNull();
});
it('opens active plugin details from the active plugin chip', () => {
const onOpenPluginDetails = vi.fn();
const active = makePlugin('prototype-plugin', 'Prototype Plugin');
render(
<HomeHero
prompt="Build a prototype"
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Prototype"
activePluginRecord={active}
activeChipId="prototype"
onClearActivePlugin={() => undefined}
onOpenPluginDetails={onOpenPluginDetails}
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
fireEvent.click(screen.getByTitle('Plugin: Prototype Plugin'));
expect(onOpenPluginDetails).toHaveBeenCalledWith(active);
});
});