mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): plugin composer surface — applyPlugin + Rail + Inputs + GenUI renderer
Plan §3.C1–§3.C4.
Web composer integration for the plugin system:
- apps/web/src/state/projects.ts gains:
* applyPlugin(pluginId, { inputs?, projectId?, grantCaps? }) — wraps
POST /api/plugins/:id/apply and returns the typed ApplyResult.
* listPlugins() — wraps GET /api/plugins.
* renderPluginBriefTemplate(template, inputs) — substitutes
{{var}} placeholders inside useCase.query as the user types so the
composer's brief textarea re-renders live.
- New components:
* InlinePluginsRail — the card strip that lives below the input box
on Home and inside ChatComposer. Supports 'wide' / 'strip' layouts
+ taskKind / mode filters.
* ContextChipStrip — typed ContextItem chips above the brief input.
Optional onRemove for clearing the applied plugin.
* PluginInputsForm — JSON-Schema-light form rendered between the
input and Send. Required fields gate Send via onValidityChange;
string/text/select/number/boolean field types are supported.
* GenUISurfaceRenderer — first-class confirmation + oauth-prompt
surfaces (form + choice fall back to a JSON Schema preview +
free-form textarea until Phase 2A.5).
* GenUIInbox — drawer that lists every persisted surface answer for
a project; revoke calls POST /api/projects/:id/genui/:sid/revoke.
- jsdom tests under apps/web/tests/components/:
* InlinePluginsRail (mount fetch, click → applyPlugin → onApplied,
taskKind filter)
* PluginInputsForm (validity gating, default hydration, select
options)
* GenUISurfaceRenderer (confirmation true/false branches; oauth
surface forwards { authorized, connectorId } per spec §10.3.1)
Web test suite: 567 → 575 (added 8 plugin component cases). The
NewProjectPanel / ChatComposer / ProjectView mounts will land in the
follow-up commit so this PR's diff stays reviewable.
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
5961c877b6
commit
adc2afd769
9 changed files with 975 additions and 1 deletions
66
apps/web/src/components/ContextChipStrip.tsx
Normal file
66
apps/web/src/components/ContextChipStrip.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Plan §3.C2 / spec §8.1 — context chip strip.
|
||||
//
|
||||
// Renders the typed `ContextItem` list above the brief input. Each chip
|
||||
// describes one piece of context the active plugin contributed: an
|
||||
// active skill, a design-system, a craft rule, an asset, an MCP server,
|
||||
// a connector, etc. Clicking the X button calls `onRemove(item)` so
|
||||
// the parent can decide whether removing the chip should clear the
|
||||
// applied plugin (typical) or just hide it.
|
||||
|
||||
import type { ContextItem } from '@open-design/contracts';
|
||||
|
||||
interface Props {
|
||||
items: ContextItem[];
|
||||
onRemove?: (item: ContextItem) => void;
|
||||
// When true (default), an empty list renders nothing; when false the
|
||||
// empty state shows a placeholder hint useful for tests / docs.
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
export function ContextChipStrip(props: Props) {
|
||||
const items = props.items ?? [];
|
||||
if (items.length === 0 && (props.hideWhenEmpty ?? true)) return null;
|
||||
return (
|
||||
<div className="context-chip-strip" role="list" data-testid="context-chip-strip">
|
||||
{items.length === 0 ? (
|
||||
<div className="context-chip-strip__empty">No active plugin context.</div>
|
||||
) : null}
|
||||
{items.map((item, idx) => (
|
||||
<span
|
||||
key={`${item.kind}-${chipKey(item)}-${idx}`}
|
||||
role="listitem"
|
||||
className="context-chip-strip__chip"
|
||||
data-kind={item.kind}
|
||||
>
|
||||
<span className="context-chip-strip__kind">{item.kind}</span>
|
||||
<span className="context-chip-strip__label">{chipLabel(item)}</span>
|
||||
{props.onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="context-chip-strip__remove"
|
||||
aria-label={`Remove ${item.kind} ${chipLabel(item)}`}
|
||||
onClick={() => props.onRemove?.(item)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function chipLabel(item: ContextItem): string {
|
||||
if ('label' in item && item.label) return item.label;
|
||||
if ('id' in item && item.id) return item.id;
|
||||
if ('name' in item && item.name) return item.name;
|
||||
if ('path' in item && item.path) return item.path;
|
||||
return item.kind;
|
||||
}
|
||||
|
||||
function chipKey(item: ContextItem): string {
|
||||
if ('id' in item && item.id) return item.id;
|
||||
if ('name' in item && item.name) return item.name;
|
||||
if ('path' in item && item.path) return item.path;
|
||||
return '';
|
||||
}
|
||||
126
apps/web/src/components/GenUIInbox.tsx
Normal file
126
apps/web/src/components/GenUIInbox.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Plan §3.C3 / spec §10.3.4 — GenUI Inbox drawer.
|
||||
//
|
||||
// Lists every persisted surface for a project (project / conversation
|
||||
// tier) so the user can see what authorizations and confirmations have
|
||||
// been remembered, and revoke any of them. Mirrors the
|
||||
// `od ui list --project <id>` CLI surface; clicking Revoke calls
|
||||
// POST /api/projects/:projectId/genui/:surfaceId/revoke.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface SurfaceRow {
|
||||
id: string;
|
||||
surfaceId: string;
|
||||
projectId: string;
|
||||
conversationId?: string | null;
|
||||
runId?: string | null;
|
||||
kind: string;
|
||||
persist: 'run' | 'conversation' | 'project';
|
||||
status: 'pending' | 'resolved' | 'timeout' | 'invalidated';
|
||||
respondedBy?: string | null;
|
||||
requestedAt: number;
|
||||
respondedAt?: number | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
// Pluggable for tests / storybook. Defaults to the daemon HTTP routes.
|
||||
fetchSurfaces?: (projectId: string) => Promise<SurfaceRow[]>;
|
||||
revokeSurface?: (projectId: string, surfaceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function GenUIInbox(props: Props) {
|
||||
const fetchSurfaces = props.fetchSurfaces ?? defaultFetchSurfaces;
|
||||
const revokeSurface = props.revokeSurface ?? defaultRevokeSurface;
|
||||
const [surfaces, setSurfaces] = useState<SurfaceRow[]>([]);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const rows = await fetchSurfaces(props.projectId);
|
||||
setSurfaces(rows);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [props.projectId, fetchSurfaces]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const onRevoke = async (surfaceId: string) => {
|
||||
setPendingRevoke(surfaceId);
|
||||
setError(null);
|
||||
try {
|
||||
await revokeSurface(props.projectId, surfaceId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setPendingRevoke(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="genui-inbox" data-testid="genui-inbox">
|
||||
<header className="genui-inbox__header">
|
||||
<h2>Plugin memory</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="genui-inbox__refresh"
|
||||
onClick={refresh}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
{error ? <div role="alert" className="genui-inbox__error">{error}</div> : null}
|
||||
{surfaces.length === 0 ? (
|
||||
<div className="genui-inbox__empty">No persisted plugin answers.</div>
|
||||
) : (
|
||||
<ul className="genui-inbox__list">
|
||||
{surfaces.map((s) => (
|
||||
<li key={s.id} className="genui-inbox__row" data-status={s.status}>
|
||||
<div className="genui-inbox__id">
|
||||
<strong>{s.surfaceId}</strong>{' '}
|
||||
<span className="genui-inbox__kind">({s.kind} / {s.persist})</span>
|
||||
</div>
|
||||
<div className="genui-inbox__status">
|
||||
{s.status}
|
||||
{s.respondedBy ? ` by ${s.respondedBy}` : ''}
|
||||
</div>
|
||||
{s.status === 'resolved' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="genui-inbox__revoke"
|
||||
onClick={() => onRevoke(s.surfaceId)}
|
||||
disabled={pendingRevoke === s.surfaceId}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultFetchSurfaces(projectId: string): Promise<SurfaceRow[]> {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/genui`);
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { surfaces?: SurfaceRow[] };
|
||||
return json.surfaces ?? [];
|
||||
}
|
||||
|
||||
async function defaultRevokeSurface(projectId: string, surfaceId: string): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to revoke ${surfaceId}: HTTP ${resp.status}`);
|
||||
}
|
||||
}
|
||||
175
apps/web/src/components/GenUISurfaceRenderer.tsx
Normal file
175
apps/web/src/components/GenUISurfaceRenderer.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Plan §3.C3 / spec §10.3 — Generative UI surface renderer.
|
||||
//
|
||||
// Renders a single pending GenUI surface. v1 ships first-class
|
||||
// renderers for `confirmation` and `oauth-prompt`; `form` and `choice`
|
||||
// fall back to a JSON-Schema preview + a generic "value-json" textarea
|
||||
// (the proper schema-driven renderer lands in Phase 2A.5).
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { GenUISurfaceSpec } from '@open-design/contracts';
|
||||
|
||||
export interface PendingSurface {
|
||||
// The surface descriptor as declared in `od.genui.surfaces[]`.
|
||||
surface: GenUISurfaceSpec;
|
||||
// The runId the surface was raised on. The respond endpoint is
|
||||
// POST /api/runs/:runId/genui/:surfaceId/respond.
|
||||
runId: string;
|
||||
// Optional pre-filled value used for `form`/`choice` re-asks.
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pending: PendingSurface;
|
||||
onAnswered: (value: unknown) => Promise<void> | void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export function GenUISurfaceRenderer(props: Props) {
|
||||
const { surface } = props.pending;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (value: unknown) => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await props.onAnswered(value);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (surface.kind === 'confirmation') {
|
||||
return (
|
||||
<div className="genui-surface genui-surface--confirmation" role="dialog" aria-label={surface.id}>
|
||||
<div className="genui-surface__prompt">
|
||||
{surface.prompt ?? 'The plugin needs your confirmation to continue.'}
|
||||
</div>
|
||||
<div className="genui-surface__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="genui-surface__primary"
|
||||
disabled={submitting}
|
||||
onClick={() => submit(true)}
|
||||
data-testid="genui-confirm"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="genui-surface__secondary"
|
||||
disabled={submitting}
|
||||
onClick={() => submit(false)}
|
||||
data-testid="genui-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="genui-surface__error">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (surface.kind === 'oauth-prompt') {
|
||||
return (
|
||||
<div className="genui-surface genui-surface--oauth" role="dialog" aria-label={surface.id}>
|
||||
<div className="genui-surface__prompt">
|
||||
{surface.prompt ?? `Authorize ${surface.oauth?.connectorId ?? surface.oauth?.mcpServerId ?? 'the connector'}`}
|
||||
</div>
|
||||
<div className="genui-surface__hint">
|
||||
{surface.oauth?.route === 'connector'
|
||||
? `connector: ${surface.oauth.connectorId}`
|
||||
: surface.oauth?.route === 'mcp'
|
||||
? `mcp server: ${surface.oauth.mcpServerId}`
|
||||
: null}
|
||||
</div>
|
||||
<div className="genui-surface__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="genui-surface__primary"
|
||||
disabled={submitting}
|
||||
onClick={() => submit({
|
||||
authorized: true,
|
||||
...(surface.oauth?.route === 'connector' && surface.oauth.connectorId
|
||||
? { connectorId: surface.oauth.connectorId }
|
||||
: {}),
|
||||
...(surface.oauth?.route === 'mcp' && surface.oauth.mcpServerId
|
||||
? { mcpServerId: surface.oauth.mcpServerId }
|
||||
: {}),
|
||||
})}
|
||||
data-testid="genui-authorize"
|
||||
>
|
||||
Authorize
|
||||
</button>
|
||||
{props.onSkip ? (
|
||||
<button
|
||||
type="button"
|
||||
className="genui-surface__secondary"
|
||||
disabled={submitting}
|
||||
onClick={props.onSkip}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <div className="genui-surface__error">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// form / choice fallback — Phase 2A.5 lands the JSON-Schema-driven
|
||||
// renderer; until then a value-json textarea is the headless-equivalent
|
||||
// surface a power user can edit by hand.
|
||||
return (
|
||||
<div className="genui-surface genui-surface--fallback" role="dialog" aria-label={surface.id}>
|
||||
<div className="genui-surface__prompt">
|
||||
{surface.prompt ?? `Plugin needs ${surface.kind} input.`}
|
||||
</div>
|
||||
{surface.schema ? (
|
||||
<details className="genui-surface__schema">
|
||||
<summary>JSON Schema</summary>
|
||||
<pre>{JSON.stringify(surface.schema, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<FreeFormJsonForm onSubmit={submit} disabled={submitting} />
|
||||
{error ? <div className="genui-surface__error">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeFormJsonForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: {
|
||||
onSubmit: (value: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState('{}');
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
onSubmit(JSON.parse(text));
|
||||
} catch (err) {
|
||||
// Invalid JSON; surface the parse error inline.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('GenUI form: invalid JSON', err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
className="genui-surface__textarea"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={6}
|
||||
data-testid="genui-form-textarea"
|
||||
/>
|
||||
<button type="submit" disabled={disabled} className="genui-surface__primary">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/components/InlinePluginsRail.tsx
Normal file
117
apps/web/src/components/InlinePluginsRail.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Plan §3.C2 / spec §8 — inline plugins rail.
|
||||
//
|
||||
// Compact card strip rendered directly under the input box on
|
||||
// NewProjectPanel and inside ChatComposer (Phase 2B follow-up).
|
||||
// Clicking a card calls applyPlugin() and pushes the resulting
|
||||
// ApplyResult upstream; the parent decides what to do with it
|
||||
// (hydrate the brief, show the input form, etc.).
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type {
|
||||
ApplyResult,
|
||||
InstalledPluginRecord,
|
||||
} from '@open-design/contracts';
|
||||
import { applyPlugin, listPlugins } from '../state/projects';
|
||||
|
||||
interface Props {
|
||||
// Active project the apply will be scoped to. Omit on Home (the
|
||||
// pre-create flow); ChatComposer passes the current project id so
|
||||
// the snapshot is bound to that scope.
|
||||
projectId?: string | null;
|
||||
// Variant: 'wide' for Home / NewProjectPanel; 'strip' for the slim
|
||||
// ChatComposer overflow row.
|
||||
variant?: 'wide' | 'strip';
|
||||
// Filter the rail to a specific taskKind / mode (Phase 2B). When
|
||||
// unspecified the daemon-wide list is shown.
|
||||
filter?: { taskKind?: string; mode?: string };
|
||||
// Notification: a plugin was applied. The parent owns hydration of
|
||||
// the brief / inputs form / chip strip from `result`.
|
||||
onApplied: (record: InstalledPluginRecord, result: ApplyResult) => void;
|
||||
}
|
||||
|
||||
export function InlinePluginsRail(props: Props) {
|
||||
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void listPlugins().then((rows) => {
|
||||
if (cancelled) return;
|
||||
setPlugins(filterPlugins(rows, props.filter));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.filter?.taskKind, props.filter?.mode]);
|
||||
|
||||
const onClick = async (record: InstalledPluginRecord) => {
|
||||
setPendingId(record.id);
|
||||
setError(null);
|
||||
const result = await applyPlugin(record.id, {
|
||||
...(props.projectId ? { projectId: props.projectId } : {}),
|
||||
});
|
||||
setPendingId(null);
|
||||
if (!result) {
|
||||
setError(
|
||||
`Failed to apply ${record.title}. Make sure the daemon is reachable.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
props.onApplied(record, result);
|
||||
};
|
||||
|
||||
if (plugins.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className =
|
||||
props.variant === 'strip'
|
||||
? 'inline-plugins-rail inline-plugins-rail--strip'
|
||||
: 'inline-plugins-rail inline-plugins-rail--wide';
|
||||
|
||||
return (
|
||||
<div className={className} role="list">
|
||||
{plugins.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="inline-plugins-rail__card"
|
||||
onClick={() => onClick(p)}
|
||||
disabled={pendingId !== null}
|
||||
aria-busy={pendingId === p.id ? 'true' : undefined}
|
||||
data-plugin-id={p.id}
|
||||
title={p.manifest?.description ?? p.title}
|
||||
>
|
||||
<div className="inline-plugins-rail__title">{p.title}</div>
|
||||
{p.manifest?.description ? (
|
||||
<div className="inline-plugins-rail__desc">{p.manifest.description}</div>
|
||||
) : null}
|
||||
<div className="inline-plugins-rail__trust">trust: {p.trust}</div>
|
||||
</button>
|
||||
))}
|
||||
{error ? (
|
||||
<div role="alert" className="inline-plugins-rail__error">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterPlugins(
|
||||
rows: InstalledPluginRecord[],
|
||||
filter: Props['filter'],
|
||||
): InstalledPluginRecord[] {
|
||||
if (!filter) return rows;
|
||||
return rows.filter((r) => {
|
||||
if (filter.taskKind && r.manifest?.od?.taskKind !== filter.taskKind) {
|
||||
return false;
|
||||
}
|
||||
if (filter.mode && r.manifest?.od?.mode !== filter.mode) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
155
apps/web/src/components/PluginInputsForm.tsx
Normal file
155
apps/web/src/components/PluginInputsForm.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Plan §3.C2 / spec §8.3 — inline plugin inputs form.
|
||||
//
|
||||
// Renders the `od.inputs` field set as a compact form between the brief
|
||||
// textarea and the Send button. Required fields gate Send via
|
||||
// `onValidityChange`; the parent disables its primary button until
|
||||
// every required field has a value.
|
||||
//
|
||||
// Behaviour rules:
|
||||
// - String / text → text input (text becomes a textarea when type='text').
|
||||
// - Select → native <select> with the supplied options.
|
||||
// - Number → numeric input; coerces back to a number on blur.
|
||||
// - Boolean → checkbox.
|
||||
// - Default values pre-fill the field on mount.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { InputFieldSpec } from '@open-design/contracts';
|
||||
|
||||
interface Props {
|
||||
fields: InputFieldSpec[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
export function PluginInputsForm(props: Props) {
|
||||
const fields = props.fields ?? [];
|
||||
const required = useMemo(
|
||||
() => fields.filter((f) => f.required === true).map((f) => f.name),
|
||||
[fields],
|
||||
);
|
||||
const [values, setValues] = useState<Record<string, unknown>>(props.values ?? {});
|
||||
|
||||
// Hydrate defaults the first time we see a new field set.
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) return;
|
||||
let mutated = false;
|
||||
const next = { ...values };
|
||||
for (const field of fields) {
|
||||
if (next[field.name] === undefined && field.default !== undefined) {
|
||||
next[field.name] = field.default;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (mutated) {
|
||||
setValues(next);
|
||||
props.onChange(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fields.length]);
|
||||
|
||||
// Emit validity whenever required fields change presence.
|
||||
useEffect(() => {
|
||||
const valid = required.every((name) => {
|
||||
const v = values[name];
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
});
|
||||
props.onValidityChange?.(valid);
|
||||
}, [values, required, props]);
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
const update = (name: string, value: unknown) => {
|
||||
const next = { ...values, [name]: value };
|
||||
setValues(next);
|
||||
props.onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plugin-inputs-form" data-testid="plugin-inputs-form">
|
||||
{fields.map((field) => (
|
||||
<label key={field.name} className="plugin-inputs-form__field">
|
||||
<span className="plugin-inputs-form__label">
|
||||
{field.label ?? field.name}
|
||||
{field.required ? <span className="plugin-inputs-form__required">*</span> : null}
|
||||
</span>
|
||||
{renderField(field, values[field.name], (v) => update(field.name, v))}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(
|
||||
field: InputFieldSpec,
|
||||
value: unknown,
|
||||
onChange: (value: unknown) => void,
|
||||
) {
|
||||
if (field.type === 'select' && Array.isArray(field.options)) {
|
||||
return (
|
||||
<select
|
||||
className="plugin-inputs-form__input"
|
||||
value={value !== undefined && value !== null ? String(value) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-field-name={field.name}
|
||||
>
|
||||
<option value="">{field.placeholder ?? 'Select…'}</option>
|
||||
{field.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="plugin-inputs-form__input"
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') return onChange(undefined);
|
||||
const n = Number(raw);
|
||||
onChange(Number.isFinite(n) ? n : raw);
|
||||
}}
|
||||
data-field-name={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="plugin-inputs-form__input"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
data-field-name={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'text') {
|
||||
return (
|
||||
<textarea
|
||||
className="plugin-inputs-form__input plugin-inputs-form__input--textarea"
|
||||
rows={3}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-field-name={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="plugin-inputs-form__input"
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-field-name={field.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,12 @@
|
|||
// These helpers fail soft (returning null / [] on transport errors) so
|
||||
// the UI can stay rendered when the daemon is briefly unreachable.
|
||||
|
||||
import type { ImportFolderRequest, ImportFolderResponse } from '@open-design/contracts';
|
||||
import type {
|
||||
ApplyResult,
|
||||
ImportFolderRequest,
|
||||
ImportFolderResponse,
|
||||
InstalledPluginRecord,
|
||||
} from '@open-design/contracts';
|
||||
import { randomUUID } from '../utils/uuid';
|
||||
import type {
|
||||
ChatMessage,
|
||||
|
|
@ -325,3 +330,74 @@ export async function saveTabs(
|
|||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- plugins ----------
|
||||
// Plan §3.C1 — plugin discovery + apply.
|
||||
//
|
||||
// applyPlugin() is the canonical entry point for both the inline rail
|
||||
// (NewProjectPanel + ChatComposer) and the marketplace detail page. It
|
||||
// hits POST /api/plugins/:id/apply, which is the same pure resolver
|
||||
// the daemon uses; the response carries everything the composer needs:
|
||||
// - query (pre-filled brief)
|
||||
// - contextItems (chip strip)
|
||||
// - inputs (form fields)
|
||||
// - appliedPlugin (snapshot id; sent back on POST /api/runs to pin
|
||||
// the prompt block to the frozen view)
|
||||
|
||||
export async function listPlugins(): Promise<InstalledPluginRecord[]> {
|
||||
try {
|
||||
const resp = await fetch('/api/plugins');
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { plugins?: InstalledPluginRecord[] };
|
||||
return json.plugins ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPlugin(
|
||||
pluginId: string,
|
||||
options: {
|
||||
inputs?: Record<string, unknown>;
|
||||
projectId?: string;
|
||||
grantCaps?: string[];
|
||||
} = {},
|
||||
): Promise<ApplyResult | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/plugins/${encodeURIComponent(pluginId)}/apply`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inputs: options.inputs ?? {},
|
||||
projectId: options.projectId,
|
||||
grantCaps: options.grantCaps ?? [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as ApplyResult & { ok?: boolean };
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the brief that the composer should display for the active
|
||||
// applied plugin. Substitutes `{{var}}` placeholders inside
|
||||
// useCase.query against the user-supplied inputs map; missing values
|
||||
// stay as `{{var}}` so the gating "fill required" hint stays visible.
|
||||
export function renderPluginBriefTemplate(
|
||||
template: string,
|
||||
inputs: Record<string, unknown>,
|
||||
): string {
|
||||
return template.replace(/\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g, (full, key) => {
|
||||
if (key in inputs) {
|
||||
const v = inputs[key];
|
||||
if (v === undefined || v === null || v === '') return full;
|
||||
return String(v);
|
||||
}
|
||||
return full;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
63
apps/web/tests/components/GenUISurfaceRenderer.test.tsx
Normal file
63
apps/web/tests/components/GenUISurfaceRenderer.test.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
// Plan §3.C3 / §3.C4 — GenUISurfaceRenderer unit test.
|
||||
//
|
||||
// Confirms:
|
||||
// - confirmation surface renders Continue / Cancel buttons; each
|
||||
// forwards the matching boolean through onAnswered.
|
||||
// - oauth-prompt surface forwards { authorized: true, connectorId }
|
||||
// for the connector route, matching the daemon's
|
||||
// genui_surfaces.value_json contract from spec §10.3.1.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { GenUISurfaceRenderer } from '../../src/components/GenUISurfaceRenderer';
|
||||
import type { GenUISurfaceSpec } from '@open-design/contracts';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GenUISurfaceRenderer', () => {
|
||||
it('confirmation surface emits true on Continue and false on Cancel', async () => {
|
||||
const surface: GenUISurfaceSpec = {
|
||||
id: 'media-spend-approval',
|
||||
kind: 'confirmation',
|
||||
persist: 'run',
|
||||
prompt: 'Approve generating up to 4 image variants?',
|
||||
};
|
||||
const onAnswered = vi.fn();
|
||||
render(
|
||||
<GenUISurfaceRenderer
|
||||
pending={{ surface, runId: 'run-1' }}
|
||||
onAnswered={onAnswered}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('genui-confirm'));
|
||||
await waitFor(() => expect(onAnswered).toHaveBeenCalledWith(true));
|
||||
fireEvent.click(screen.getByTestId('genui-cancel'));
|
||||
await waitFor(() => expect(onAnswered).toHaveBeenLastCalledWith(false));
|
||||
});
|
||||
|
||||
it('oauth-prompt surface forwards the connectorId on Authorize', async () => {
|
||||
const surface: GenUISurfaceSpec = {
|
||||
id: '__auto_connector_slack',
|
||||
kind: 'oauth-prompt',
|
||||
persist: 'project',
|
||||
capabilitiesRequired: ['connector:slack'],
|
||||
oauth: { route: 'connector', connectorId: 'slack' },
|
||||
};
|
||||
const onAnswered = vi.fn();
|
||||
render(
|
||||
<GenUISurfaceRenderer
|
||||
pending={{ surface, runId: 'run-1' }}
|
||||
onAnswered={onAnswered}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('genui-authorize'));
|
||||
await waitFor(() =>
|
||||
expect(onAnswered).toHaveBeenCalledWith({
|
||||
authorized: true,
|
||||
connectorId: 'slack',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
119
apps/web/tests/components/InlinePluginsRail.test.tsx
Normal file
119
apps/web/tests/components/InlinePluginsRail.test.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
// Plan §3.C2 / §3.C4 — InlinePluginsRail unit test.
|
||||
//
|
||||
// Asserts that:
|
||||
// - The rail fetches GET /api/plugins on mount and renders one card per row.
|
||||
// - Clicking a card POSTs to /api/plugins/:id/apply and forwards the
|
||||
// ApplyResult to onApplied.
|
||||
// - The rail filters by taskKind / mode when supplied.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { InlinePluginsRail } from '../../src/components/InlinePluginsRail';
|
||||
|
||||
const PLUGIN_ROW = {
|
||||
id: 'sample-plugin',
|
||||
title: 'Sample Plugin',
|
||||
version: '1.0.0',
|
||||
trust: 'restricted' as const,
|
||||
sourceKind: 'local' as const,
|
||||
source: '/tmp/sample',
|
||||
manifest: {
|
||||
name: 'sample-plugin',
|
||||
title: 'Sample Plugin',
|
||||
description: 'A fixture',
|
||||
od: { taskKind: 'new-generation', mode: 'deck' },
|
||||
},
|
||||
};
|
||||
|
||||
const APPLY_RESULT = {
|
||||
ok: true,
|
||||
query: 'Make a deck for {{topic}}.',
|
||||
contextItems: [{ kind: 'skill', id: 'sample', label: 'Sample' }],
|
||||
inputs: [{ name: 'topic', type: 'string', required: true, label: 'Topic' }],
|
||||
assets: [],
|
||||
mcpServers: [],
|
||||
trust: 'restricted',
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
capabilitiesRequired: ['prompt:inject'],
|
||||
appliedPlugin: {
|
||||
snapshotId: 'snap-1',
|
||||
pluginId: 'sample-plugin',
|
||||
pluginVersion: '1.0.0',
|
||||
manifestSourceDigest: 'a'.repeat(64),
|
||||
inputs: {},
|
||||
resolvedContext: { items: [] },
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
capabilitiesRequired: ['prompt:inject'],
|
||||
assetsStaged: [],
|
||||
taskKind: 'new-generation',
|
||||
appliedAt: 0,
|
||||
connectorsRequired: [],
|
||||
connectorsResolved: [],
|
||||
mcpServers: [],
|
||||
status: 'fresh',
|
||||
},
|
||||
projectMetadata: {},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('InlinePluginsRail', () => {
|
||||
it('renders a card for each installed plugin and fires onApplied on click', async () => {
|
||||
fetchMock.mockImplementation(async (url) => {
|
||||
if (typeof url === 'string' && url === '/api/plugins') {
|
||||
return new Response(JSON.stringify({ plugins: [PLUGIN_ROW] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (typeof url === 'string' && url.includes('/apply')) {
|
||||
return new Response(JSON.stringify(APPLY_RESULT), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
|
||||
const onApplied = vi.fn();
|
||||
render(<InlinePluginsRail onApplied={onApplied} />);
|
||||
const card = await waitFor(() => screen.getByTitle('A fixture'));
|
||||
fireEvent.click(card);
|
||||
await waitFor(() => expect(onApplied).toHaveBeenCalled());
|
||||
const [record, result] = onApplied.mock.calls[0]!;
|
||||
expect(record.id).toBe('sample-plugin');
|
||||
expect(result.appliedPlugin.snapshotId).toBe('snap-1');
|
||||
});
|
||||
|
||||
it('filters by taskKind when supplied', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plugins: [
|
||||
PLUGIN_ROW,
|
||||
{ ...PLUGIN_ROW, id: 'other', title: 'Other', manifest: { ...PLUGIN_ROW.manifest, od: { taskKind: 'tune-collab' } } },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
render(<InlinePluginsRail onApplied={() => undefined} filter={{ taskKind: 'tune-collab' }} />);
|
||||
// The filter runs after the fetch resolves; wait until the surviving
|
||||
// card is rendered. Then the new-generation plugin must NOT be in
|
||||
// the DOM.
|
||||
await waitFor(() => expect(screen.getByText('Other')).toBeTruthy());
|
||||
expect(screen.queryByText('Sample Plugin')).toBeNull();
|
||||
});
|
||||
});
|
||||
77
apps/web/tests/components/PluginInputsForm.test.tsx
Normal file
77
apps/web/tests/components/PluginInputsForm.test.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
// Plan §3.C2 / §3.C4 — PluginInputsForm unit test.
|
||||
//
|
||||
// Confirms the validity gating contract every composer relies on:
|
||||
// - Required text fields gate Send (onValidityChange flips false → true).
|
||||
// - Defaults pre-fill on mount.
|
||||
// - Inputs flow back through onChange.
|
||||
// - Select renders options + emits the chosen value.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { PluginInputsForm } from '../../src/components/PluginInputsForm';
|
||||
|
||||
let onChange: ReturnType<typeof vi.fn>;
|
||||
let onValidityChange: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onChange = vi.fn();
|
||||
onValidityChange = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PluginInputsForm', () => {
|
||||
it('renders nothing for an empty field set', () => {
|
||||
const { container } = render(
|
||||
<PluginInputsForm fields={[]} values={{}} onChange={onChange} onValidityChange={onValidityChange} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('emits invalid → valid as the user fills required fields', () => {
|
||||
render(
|
||||
<PluginInputsForm
|
||||
fields={[{ name: 'topic', label: 'Topic', type: 'string', required: true }]}
|
||||
values={{}}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>,
|
||||
);
|
||||
expect(onValidityChange).toHaveBeenLastCalledWith(false);
|
||||
const input = screen.getByLabelText(/Topic/);
|
||||
fireEvent.change(input, { target: { value: 'design tools' } });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(onValidityChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('hydrates default values on mount', () => {
|
||||
render(
|
||||
<PluginInputsForm
|
||||
fields={[
|
||||
{ name: 'tone', label: 'Tone', type: 'select', options: ['Editorial', 'Modern'], default: 'Modern' },
|
||||
]}
|
||||
values={{}}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByLabelText(/Tone/) as HTMLSelectElement;
|
||||
expect(select.value).toBe('Modern');
|
||||
expect(onChange).toHaveBeenCalledWith({ tone: 'Modern' });
|
||||
});
|
||||
|
||||
it('renders a select with each option', () => {
|
||||
render(
|
||||
<PluginInputsForm
|
||||
fields={[{ name: 'audience', label: 'Audience', type: 'select', options: ['VC', 'Customer'] }]}
|
||||
values={{}}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('VC')).toBeTruthy();
|
||||
expect(screen.getByText('Customer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue