mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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>
396 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|