mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 14:38:42 +08:00
feat(plugin-inputs): enhance plugin input handling with file upload support
- Added support for file input fields in the PluginInputsForm, allowing users to upload files with serializable metadata. - Updated the HomeHero component to improve the layout and interaction of input fields, enhancing user experience. - Adjusted CSS styles for better visual representation of input fields and their states. - Modified HomeView to reflect changes in authoring chip IDs for better clarity in plugin actions. - Enhanced tests to cover new file input functionality and ensure correct behavior in various scenarios. This update significantly improves the plugin input handling, enabling users to upload files seamlessly and enhancing the overall interaction model.
This commit is contained in:
@@ -321,72 +321,78 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="home-hero__input"
|
||||
data-testid="home-hero-input"
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
onPromptChange(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isImeComposing(e, composingRef.current)) return;
|
||||
if (pickerOpen && visiblePickerOptions.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((idx) => (idx + 1) % visiblePickerOptions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(
|
||||
(idx) => (idx - 1 + visiblePickerOptions.length) % visiblePickerOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
|
||||
if (selected && !selected.disabled) selected.onPick();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
<div
|
||||
className={`home-hero__prompt-surface${
|
||||
pluginInputFields.length > 0 ? ' home-hero__prompt-surface--with-inputs' : ''
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="home-hero__input"
|
||||
data-testid="home-hero-input"
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
onPromptChange(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isImeComposing(e, composingRef.current)) return;
|
||||
if (pickerOpen && visiblePickerOptions.length > 0) {
|
||||
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
|
||||
if (selected && !selected.disabled) selected.onPick();
|
||||
return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((idx) => (idx + 1) % visiblePickerOptions.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(
|
||||
(idx) => (idx - 1 + visiblePickerOptions.length) % visiblePickerOptions.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
|
||||
if (selected && !selected.disabled) selected.onPick();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (canSubmit) onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
aria-controls={pickerOpen ? 'home-hero-context-picker' : undefined}
|
||||
aria-expanded={pickerOpen}
|
||||
/>
|
||||
{pluginInputFields.length > 0 ? (
|
||||
<PluginInputsForm
|
||||
fields={pluginInputFields}
|
||||
values={pluginInputValues}
|
||||
onChange={onPluginInputValuesChange}
|
||||
onValidityChange={onPluginInputValidityChange}
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (pickerOpen && visiblePickerOptions.length > 0) {
|
||||
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
|
||||
if (selected && !selected.disabled) selected.onPick();
|
||||
return;
|
||||
}
|
||||
if (canSubmit) onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
aria-controls={pickerOpen ? 'home-hero-context-picker' : undefined}
|
||||
aria-expanded={pickerOpen}
|
||||
/>
|
||||
) : null}
|
||||
{pluginInputFields.length > 0 ? (
|
||||
<PluginInputsForm
|
||||
fields={pluginInputFields}
|
||||
values={pluginInputValues}
|
||||
onChange={onPluginInputValuesChange}
|
||||
onValidityChange={onPluginInputValidityChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{pickerOpen ? (
|
||||
<div
|
||||
id="home-hero-context-picker"
|
||||
|
||||
@@ -183,7 +183,7 @@ export function HomeView({
|
||||
if (promptHandoff.focus) {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
setPendingAuthoringChipId('plugin-authoring');
|
||||
setPendingAuthoringChipId('create-plugin');
|
||||
}, [promptHandoff]);
|
||||
|
||||
const contextItemCount = useMemo(
|
||||
@@ -451,7 +451,7 @@ export function HomeView({
|
||||
setError(null);
|
||||
setPrompt(nextPrompt);
|
||||
setPendingAuthoringPrompt(nextPrompt);
|
||||
setPendingAuthoringChipId(chipId ?? 'plugin-authoring');
|
||||
setPendingAuthoringChipId(chipId ?? 'create-plugin');
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
});
|
||||
}
|
||||
@@ -471,7 +471,7 @@ export function HomeView({
|
||||
}
|
||||
void usePlugin(record, pendingAuthoringPrompt, {
|
||||
projectKind: 'other',
|
||||
chipId: pendingAuthoringChipId === 'plugin-authoring' ? undefined : pendingAuthoringChipId,
|
||||
chipId: pendingAuthoringChipId,
|
||||
...(authoringRecord ? {} : { inputs: AUTHORING_DEFAULT_SCENARIO_INPUTS }),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// - Select → native <select> with the supplied options.
|
||||
// - Number → numeric input; coerces back to a number on blur.
|
||||
// - Boolean → checkbox.
|
||||
// - File → upload picker; the value stored for apply is lightweight
|
||||
// metadata so project creation can still pass JSON cleanly.
|
||||
// - Default values pre-fill the field on mount.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -68,7 +70,13 @@ export function PluginInputsForm(props: Props) {
|
||||
return (
|
||||
<div className="plugin-inputs-form" data-testid="plugin-inputs-form">
|
||||
{fields.map((field) => (
|
||||
<label key={field.name} className="plugin-inputs-form__field">
|
||||
<label
|
||||
key={field.name}
|
||||
className="plugin-inputs-form__field"
|
||||
data-field-type={fieldType(field)}
|
||||
data-required={field.required === true ? 'true' : 'false'}
|
||||
data-filled={hasFieldValue(values[field.name]) ? 'true' : 'false'}
|
||||
>
|
||||
<span className="plugin-inputs-form__label">
|
||||
{field.label ?? field.name}
|
||||
{field.required ? <span className="plugin-inputs-form__required">*</span> : null}
|
||||
@@ -85,7 +93,8 @@ function renderField(
|
||||
value: unknown,
|
||||
onChange: (value: unknown) => void,
|
||||
) {
|
||||
if (field.type === 'select' && Array.isArray(field.options)) {
|
||||
const type = fieldType(field);
|
||||
if (type === 'select' && Array.isArray(field.options)) {
|
||||
return (
|
||||
<select
|
||||
className="plugin-inputs-form__input"
|
||||
@@ -102,7 +111,7 @@ function renderField(
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
if (type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
@@ -119,7 +128,7 @@ function renderField(
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -130,7 +139,27 @@ function renderField(
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'text') {
|
||||
if (type === 'file') {
|
||||
const fileValue = fileInputLabel(value);
|
||||
return (
|
||||
<span className="plugin-inputs-form__file-shell">
|
||||
<input
|
||||
type="file"
|
||||
className="plugin-inputs-form__input plugin-inputs-form__input--file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
onChange(file ? fileMetadata(file) : undefined);
|
||||
}}
|
||||
data-field-name={field.name}
|
||||
{...(typeof field.accept === 'string' ? { accept: field.accept } : {})}
|
||||
/>
|
||||
<span className="plugin-inputs-form__file-label">
|
||||
{fileValue ?? field.placeholder ?? 'Choose file…'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<textarea
|
||||
className="plugin-inputs-form__input plugin-inputs-form__input--textarea"
|
||||
@@ -153,3 +182,27 @@ function renderField(
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldType(field: InputFieldSpec): string {
|
||||
const raw = typeof field.type === 'string' ? field.type : 'string';
|
||||
return raw === 'upload' ? 'file' : raw;
|
||||
}
|
||||
|
||||
function hasFieldValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}
|
||||
|
||||
function fileMetadata(file: File) {
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
}
|
||||
|
||||
function fileInputLabel(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const name = (value as { name?: unknown }).name;
|
||||
return typeof name === 'string' && name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ const PLUGINS_TABS: ReadonlyArray<{
|
||||
{ id: 'team', label: 'Team', hint: 'Enterprise' },
|
||||
];
|
||||
|
||||
const COMMUNITY_MARKETPLACE_SOURCE_URL =
|
||||
'https://raw.githubusercontent.com/nexu-io/open-design/garnet-hemisphere/plugins/registry/community/open-design-marketplace.json';
|
||||
|
||||
const PLUGIN_SHARE_DETAILS: Record<PluginShareAction, {
|
||||
eyebrow: string;
|
||||
fallbackTitle: string;
|
||||
@@ -472,7 +475,7 @@ function PluginShareConfirmModal({
|
||||
aria-label="Close share confirmation"
|
||||
title="Close"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -762,7 +765,7 @@ function SourcesPanel({
|
||||
id="plugin-marketplace-url"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://open-design.ai/marketplace/open-design-marketplace.json"
|
||||
placeholder={COMMUNITY_MARKETPLACE_SOURCE_URL}
|
||||
disabled={pendingAction === 'add'}
|
||||
/>
|
||||
<select
|
||||
|
||||
@@ -113,7 +113,7 @@ export function PluginScenarioDetail({
|
||||
aria-label="Close details"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -15610,8 +15610,8 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -15619,6 +15619,11 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.plugin-details-modal__close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.plugin-details-modal__close:hover,
|
||||
.plugin-details-modal__close:focus-visible {
|
||||
background: var(--bg-subtle);
|
||||
|
||||
@@ -164,6 +164,23 @@
|
||||
.home-hero__context-summary {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.home-hero__prompt-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
border-radius: calc(var(--radius-lg) - 6px);
|
||||
}
|
||||
.home-hero__prompt-surface--with-inputs {
|
||||
padding: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border-soft));
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--accent) 6%, transparent),
|
||||
color-mix(in srgb, var(--accent) 2%, transparent)
|
||||
);
|
||||
}
|
||||
.home-hero__input {
|
||||
width: 100%;
|
||||
min-height: 84px;
|
||||
@@ -186,6 +203,94 @@
|
||||
.home-hero__input::placeholder {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--accent) 36%, var(--border-soft));
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__field {
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent-tint) 62%, var(--bg-panel));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, white 42%, transparent);
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__field[data-filled='true'] {
|
||||
border-color: color-mix(in srgb, var(--accent) 52%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent-tint) 78%, var(--bg-panel));
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-panel));
|
||||
color: var(--accent);
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__required {
|
||||
color: var(--accent);
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, var(--border));
|
||||
background: var(--bg-panel);
|
||||
font-size: 13px;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__input:focus {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__input--textarea {
|
||||
min-height: 64px;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__field[data-field-type='boolean'] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__field[data-field-type='boolean'] .plugin-inputs-form__input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__file-shell {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__input--file {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__file-label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-hero__input-card .plugin-inputs-form__file-shell:focus-within .plugin-inputs-form__file-label {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.home-hero__plugin-picker {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
|
||||
@@ -533,6 +533,11 @@ describe('HomeView prompt handoff', () => {
|
||||
'/api/plugins/od-plugin-authoring/apply',
|
||||
expect.anything(),
|
||||
));
|
||||
await waitFor(() => {
|
||||
const badge = screen.getByTestId('home-hero-active-plugin');
|
||||
expect(badge.textContent).toContain('Create plugin');
|
||||
expect(badge.textContent).not.toContain('Plugin authoring');
|
||||
});
|
||||
fireEvent.click(await screen.findByTestId('home-hero-submit'));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
||||
@@ -74,4 +74,28 @@ describe('PluginInputsForm', () => {
|
||||
expect(screen.getByText('VC')).toBeTruthy();
|
||||
expect(screen.getByText('Customer')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders file inputs as upload slots with serializable metadata', () => {
|
||||
render(
|
||||
<PluginInputsForm
|
||||
fields={[{ name: 'reference', label: 'Reference file', type: 'file' }]}
|
||||
values={{}}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(/Reference file/) as HTMLInputElement;
|
||||
const file = new File(['brief'], 'brief.txt', { type: 'text/plain' });
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
reference: expect.objectContaining({
|
||||
name: 'brief.txt',
|
||||
size: 5,
|
||||
type: 'text/plain',
|
||||
}),
|
||||
});
|
||||
expect(screen.getByText('brief.txt')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,7 +412,7 @@ When a plugin has no `open-design.json`, but its `SKILL.md` already contains the
|
||||
| `od.preview` | `od.preview` | Only `type` and `entry` enter the v1 manifest; `reload` is kept as adapter metadata and does not enter the public contract |
|
||||
| `od.design_system.requires` | `od.context.designSystem` | `true` means use the active project design system at run time; `sections` is preserved in resolved context as a prompt-pruning hint |
|
||||
| `od.craft.requires` | `od.context.craft` | Slug array maps directly |
|
||||
| `od.inputs` | `od.inputs` | `string` → `string`, `integer` → `number`, `enum` → `select`, `values` → `options`; `min` / `max` are preserved as future metadata, and v1 UI may ignore but must not discard them |
|
||||
| `od.inputs` | `od.inputs` | `string` → `string`, `integer` → `number`, `enum` → `select`, `upload` → `file`, `values` → `options`; `min` / `max` are preserved as future metadata, and v1 UI may ignore but must not discard them |
|
||||
| `od.parameters` | adapter metadata | v1 plugin apply does not render live sliders; fields are preserved for Phase 4 and do not enter `ApplyResult.inputs` |
|
||||
| `od.outputs` | `projectMetadata` hints | Used for artifact bookkeeping and preview defaults, not surfaced as user-editable inputs |
|
||||
| `od.capabilities_required` | `od.capabilities` | Map only capabilities that can be expressed; unknown capabilities are kept in `compatWarnings[]`, and `od plugin doctor` must surface them |
|
||||
@@ -629,12 +629,12 @@ Only the daemon writes `AppliedPluginSnapshot`; CLI/UI clients are read-only. Pl
|
||||
|
||||
### 8.3 Inline `od.inputs` form
|
||||
|
||||
When the applied plugin declares `od.inputs`, the composer renders a `PluginInputsForm` between the brief textarea and the Send button. Behavior:
|
||||
When the applied plugin declares `od.inputs`, the composer renders a highlighted `PluginInputsForm` inside the brief input surface. Behavior:
|
||||
|
||||
- Required fields gate Send (the button is disabled with a tooltip listing missing fields).
|
||||
- Plugins with required inputs may be selected before all answers are present. The client should render the form from manifest-declared `od.inputs`, then apply/pin the snapshot once required values are available.
|
||||
- As the user types, `{{var}}` placeholders inside `useCase.query` and inside any string-valued `context` entry re-render live, so the user sees the final brief and final chip labels before sending.
|
||||
- The form is compact by default — short fields render inline like a search bar; long-text fields collapse to a "Add details" expander.
|
||||
- The form is compact by default — short fields render inline like fill-in-the-blank slots; `select`, `boolean`, and `file` fields use native controls, and long text fields expand within the same input surface.
|
||||
- On Send, input values are sent alongside the run request; the daemon also passes them into the prompt under a small `## Plugin inputs` block so the agent has the literal user-supplied values, not just the post-template brief.
|
||||
- Inputs persist in component state until the user clears the chip strip — re-applying the same plugin on the same composer pre-fills last-used values.
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string" },
|
||||
"type": { "type": "string", "enum": ["string", "text", "select", "number", "boolean"] },
|
||||
"type": { "type": "string", "enum": ["string", "text", "select", "number", "boolean", "file"] },
|
||||
"required": { "type": "boolean" },
|
||||
"options": { "type": "array", "items": { "type": "string" } },
|
||||
"placeholder": { "type": "string" },
|
||||
|
||||
@@ -31,7 +31,7 @@ export type McpServerSpec = z.infer<typeof McpServerSpecSchema>;
|
||||
export const InputFieldSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
label: z.string().optional(),
|
||||
type: z.enum(['string', 'text', 'select', 'number', 'boolean']).optional(),
|
||||
type: z.enum(['string', 'text', 'select', 'number', 'boolean', 'file']).optional(),
|
||||
required: z.boolean().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
|
||||
@@ -134,7 +134,8 @@ function mapInputs(value: FrontmatterValue | undefined, warnings: string[]): Inp
|
||||
let mappedType: InputField['type'];
|
||||
if (t === 'integer') mappedType = 'number';
|
||||
else if (t === 'enum') mappedType = 'select';
|
||||
else if (t === 'string' || t === 'text' || t === 'select' || t === 'number' || t === 'boolean') mappedType = t;
|
||||
else if (t === 'upload') mappedType = 'file';
|
||||
else if (t === 'string' || t === 'text' || t === 'select' || t === 'number' || t === 'boolean' || t === 'file') mappedType = t;
|
||||
else {
|
||||
warnings.push(`SKILL.md inputs[${name}].type='${t}' is not in the v1 input vocabulary; falling back to 'string'`);
|
||||
mappedType = 'string';
|
||||
|
||||
@@ -54,6 +54,7 @@ const BUNDLED_RESOURCE_TREES = [
|
||||
{ from: "skills", to: "skills" },
|
||||
{ from: "design-systems", to: "design-systems" },
|
||||
{ from: "craft", to: "craft" },
|
||||
{ from: join("plugins", "registry"), to: join("plugins", "registry") },
|
||||
{ from: join("assets", "frames"), to: "frames" },
|
||||
{ from: join("assets", "community-pets"), to: "community-pets" },
|
||||
{ from: "prompt-templates", to: "prompt-templates" },
|
||||
|
||||
@@ -13,6 +13,7 @@ async function createResourceTreeCacheKey(config: ToolPackConfig): Promise<strin
|
||||
craft: await hashPath(join(config.workspaceRoot, "craft")),
|
||||
designSystems: await hashPath(join(config.workspaceRoot, "design-systems")),
|
||||
node: "win.resource-tree",
|
||||
pluginRegistry: await hashPath(join(config.workspaceRoot, "plugins", "registry")),
|
||||
promptTemplates: await hashPath(join(config.workspaceRoot, "prompt-templates")),
|
||||
schemaVersion: 1,
|
||||
skills: await hashPath(join(config.workspaceRoot, "skills")),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
||||
import { copyBundledResourceTrees } from "../src/resources.js";
|
||||
|
||||
describe("copyBundledResourceTrees", () => {
|
||||
it("includes prompt templates", async () => {
|
||||
it("includes daemon resource trees", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-"));
|
||||
const workspaceRoot = join(root, "workspace");
|
||||
const resourceRoot = join(root, "resources");
|
||||
@@ -25,11 +25,21 @@ describe("copyBundledResourceTrees", () => {
|
||||
"sample",
|
||||
"pet.json",
|
||||
);
|
||||
const communityRegistryPath = join(
|
||||
workspaceRoot,
|
||||
"plugins",
|
||||
"registry",
|
||||
"community",
|
||||
"open-design-marketplace.json",
|
||||
);
|
||||
await mkdir(join(workspaceRoot, "skills", "sample"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "design-systems", "sample"), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(workspaceRoot, "craft", "sample"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "plugins", "registry", "community"), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(workspaceRoot, "assets", "frames"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "assets", "community-pets", "sample"), {
|
||||
recursive: true,
|
||||
@@ -39,6 +49,7 @@ describe("copyBundledResourceTrees", () => {
|
||||
});
|
||||
await writeFile(promptTemplatePath, "{\"id\":\"sample\"}\n", "utf8");
|
||||
await writeFile(communityPetPath, "{\"name\":\"sample\"}\n", "utf8");
|
||||
await writeFile(communityRegistryPath, "{\"plugins\":[]}\n", "utf8");
|
||||
|
||||
await copyBundledResourceTrees({ workspaceRoot, resourceRoot });
|
||||
|
||||
@@ -54,6 +65,18 @@ describe("copyBundledResourceTrees", () => {
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe("{\"name\":\"sample\"}\n");
|
||||
await expect(
|
||||
readFile(
|
||||
join(
|
||||
resourceRoot,
|
||||
"plugins",
|
||||
"registry",
|
||||
"community",
|
||||
"open-design-marketplace.json",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe("{\"plugins\":[]}\n");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user