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:
Cursor Agent 2026-05-09 11:47:12 +00:00
parent 5961c877b6
commit adc2afd769
No known key found for this signature in database
9 changed files with 975 additions and 1 deletions

View 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 '';
}

View 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}`);
}
}

View 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>
);
}

View 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;
});
}

View 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}
/>
);
}

View file

@ -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;
});
}

View 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',
}),
);
});
});

View 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();
});
});

View 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();
});
});