mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import type {
|
||
ApplyResult,
|
||
InstalledPluginRecord,
|
||
ProjectMetadata,
|
||
} from '@open-design/contracts';
|
||
import {
|
||
applyPlugin,
|
||
listPlugins,
|
||
resolvePluginQueryFallback,
|
||
} from '../state/projects';
|
||
import { useI18n } from '../i18n';
|
||
import { renderLocalizedPluginBriefTemplate } from '../i18n/plugin-content';
|
||
import { Icon } from './Icon';
|
||
import { PluginDetailsModal } from './PluginDetailsModal';
|
||
import { TrustBadge } from './TrustBadge';
|
||
import { authorInitials, derivePluginSourceLinks } from '../runtime/plugin-source';
|
||
import { useAnalytics } from '../analytics/provider';
|
||
import { trackPluginLoopClick } from '../analytics/events';
|
||
|
||
export interface PluginLoopSubmit {
|
||
prompt: string;
|
||
pluginId: string | null;
|
||
skillId?: string | null;
|
||
appliedPluginSnapshotId: string | null;
|
||
pluginTitle: string | null;
|
||
taskKind: string | null;
|
||
pluginInputs?: Record<string, unknown> | null;
|
||
contextPlugins?: Array<{ id: string; title: string; description?: string }> | null;
|
||
contextMcpServers?: Array<{ id: string; label?: string; transport?: string; url?: string; command?: string }> | null;
|
||
contextConnectors?: Array<{ id: string; name: string; provider?: string; category?: string; status?: string; accountLabel?: string }> | null;
|
||
designSystemId?: string | null;
|
||
// Stage B of plugin-driven-flow-plan: when the user picked a Home
|
||
// chip the rail tells the submit handler which `ProjectKind` to
|
||
// stamp on the new project's metadata. The daemon-side default
|
||
// binding then resolves to the matching scenario plugin (image /
|
||
// video / audio → od-media-generation, others → od-new-generation).
|
||
// Null means the caller did not stamp an explicit kind. HomeView's
|
||
// free-form fallback uses `other` and binds the hidden od-default
|
||
// router plugin so the agent asks for the exact task type in-chat.
|
||
projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null;
|
||
projectMetadata?: ProjectMetadata | null;
|
||
workingDir?: string | null;
|
||
// Files staged on Home before the project exists. App uploads them
|
||
// into the created project's Design Files before the first auto-send.
|
||
attachments?: File[];
|
||
}
|
||
|
||
interface Props {
|
||
onSubmit: (payload: PluginLoopSubmit) => void;
|
||
}
|
||
|
||
interface ActivePlugin {
|
||
record: InstalledPluginRecord;
|
||
result: ApplyResult;
|
||
inputs: Record<string, unknown>;
|
||
}
|
||
|
||
export function PluginLoopHome({ onSubmit }: Props) {
|
||
const { locale, t } = useI18n();
|
||
const analytics = useAnalytics();
|
||
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
|
||
const [active, setActive] = useState<ActivePlugin | null>(null);
|
||
const [prompt, setPrompt] = useState('');
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [detailsRecord, setDetailsRecord] =
|
||
useState<InstalledPluginRecord | null>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void listPlugins().then((rows) => {
|
||
if (cancelled) return;
|
||
setPlugins(rows);
|
||
setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const sortedPlugins = useMemo(() => {
|
||
return [...plugins].sort((a, b) => {
|
||
const aHasQuery = Boolean(a.manifest?.od?.useCase?.query);
|
||
const bHasQuery = Boolean(b.manifest?.od?.useCase?.query);
|
||
if (aHasQuery !== bHasQuery) return aHasQuery ? -1 : 1;
|
||
const aScenario = a.manifest?.od?.kind === 'scenario';
|
||
const bScenario = b.manifest?.od?.kind === 'scenario';
|
||
if (aScenario !== bScenario) return aScenario ? -1 : 1;
|
||
return a.title.localeCompare(b.title);
|
||
});
|
||
}, [plugins]);
|
||
|
||
async function usePlugin(record: InstalledPluginRecord) {
|
||
setPendingApplyId(record.id);
|
||
setError(null);
|
||
const result = await applyPlugin(record.id, { locale });
|
||
setPendingApplyId(null);
|
||
if (!result) {
|
||
setError(t('home.error.applyFailed', { title: record.title }));
|
||
return;
|
||
}
|
||
const inputs: Record<string, unknown> = {};
|
||
for (const field of result.inputs ?? []) {
|
||
if (field.default !== undefined) inputs[field.name] = field.default;
|
||
}
|
||
setActive({ record, result, inputs });
|
||
const query = result.query || resolvePluginQueryFallback(record.manifest?.od?.useCase?.query, locale);
|
||
if (query) {
|
||
setPrompt(renderLocalizedPluginBriefTemplate(locale, query, inputs));
|
||
}
|
||
setDetailsRecord(null);
|
||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||
}
|
||
|
||
function openDetails(record: InstalledPluginRecord) {
|
||
setDetailsRecord(record);
|
||
}
|
||
|
||
function closeDetails() {
|
||
setDetailsRecord(null);
|
||
}
|
||
|
||
function clearActive() {
|
||
setActive(null);
|
||
setPrompt('');
|
||
}
|
||
|
||
function submit() {
|
||
const trimmed = prompt.trim();
|
||
if (!trimmed) return;
|
||
trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'submit', plugin_id: active?.record.id });
|
||
onSubmit({
|
||
prompt: trimmed,
|
||
pluginId: active?.record.id ?? null,
|
||
appliedPluginSnapshotId: active?.result.appliedPlugin?.snapshotId ?? null,
|
||
pluginTitle: active?.record.title ?? null,
|
||
taskKind: active?.result.appliedPlugin?.taskKind ?? null,
|
||
});
|
||
}
|
||
|
||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (
|
||
e.key === 'Enter' &&
|
||
!e.shiftKey &&
|
||
!e.metaKey &&
|
||
!e.ctrlKey &&
|
||
!e.altKey
|
||
) {
|
||
e.preventDefault();
|
||
submit();
|
||
}
|
||
}
|
||
|
||
const canSubmit = prompt.trim().length > 0;
|
||
|
||
return (
|
||
<div className="plugin-loop-home" data-testid="plugin-loop-home">
|
||
<div className="plugin-loop-home__hero">
|
||
<h2 className="plugin-loop-home__title">What do you want to design?</h2>
|
||
<p className="plugin-loop-home__subtitle">
|
||
Pick a plugin below, click <strong>Use example query</strong> to load
|
||
a starter prompt, then press <kbd>Enter</kbd>.
|
||
</p>
|
||
{active ? (
|
||
<div className="plugin-loop-home__active" data-active-plugin-id={active.record.id}>
|
||
<span className="plugin-loop-home__active-chip">
|
||
<span className="plugin-loop-home__active-dot" aria-hidden />
|
||
<span>Plugin: {active.record.title}</span>
|
||
<button
|
||
type="button"
|
||
className="plugin-loop-home__active-clear"
|
||
onClick={() => { trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'clear_active', plugin_id: active?.record.id }); clearActive(); }}
|
||
aria-label="Clear active plugin"
|
||
title="Clear active plugin"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
{active.result.contextItems && active.result.contextItems.length > 0 ? (
|
||
<span className="plugin-loop-home__context-summary">
|
||
{active.result.contextItems.length} context items resolved
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div className="plugin-loop-home__input-wrap">
|
||
<textarea
|
||
ref={textareaRef}
|
||
className="plugin-loop-home__input"
|
||
data-testid="plugin-loop-input"
|
||
value={prompt}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
placeholder={
|
||
active
|
||
? 'Edit the example query or write your own…'
|
||
: 'Type a prompt, or pick a plugin below to load an example…'
|
||
}
|
||
rows={3}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="plugin-loop-home__submit"
|
||
data-testid="plugin-loop-submit"
|
||
onClick={submit}
|
||
disabled={!canSubmit}
|
||
title={canSubmit ? 'Press Enter to run' : 'Type something to run'}
|
||
>
|
||
Run ↵
|
||
</button>
|
||
</div>
|
||
{error ? (
|
||
<div role="alert" className="plugin-loop-home__error">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="plugin-loop-home__rail-header">
|
||
<span>Plugins</span>
|
||
<span className="plugin-loop-home__rail-count">
|
||
{loading ? '…' : `${sortedPlugins.length} installed`}
|
||
</span>
|
||
</div>
|
||
<div className="plugin-loop-home__grid" role="list">
|
||
{loading ? (
|
||
<div className="plugin-loop-home__empty">Loading plugins…</div>
|
||
) : sortedPlugins.length === 0 ? (
|
||
<div className="plugin-loop-home__empty">
|
||
No plugins installed. Install one with{' '}
|
||
<code>od plugin install <source></code>.
|
||
</div>
|
||
) : (
|
||
sortedPlugins.map((p) => {
|
||
const hasQuery = Boolean(p.manifest?.od?.useCase?.query);
|
||
const isActive = active?.record.id === p.id;
|
||
const isPending = pendingApplyId === p.id;
|
||
const links = derivePluginSourceLinks(p);
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
role="listitem"
|
||
className={`plugin-loop-home__card${isActive ? ' is-active' : ''}`}
|
||
data-plugin-id={p.id}
|
||
>
|
||
<div className="plugin-loop-home__card-head">
|
||
<span className="plugin-loop-home__card-title">{p.title}</span>
|
||
<TrustBadge trust={p.trust} />
|
||
</div>
|
||
{p.manifest?.description ? (
|
||
<div className="plugin-loop-home__card-desc">
|
||
{p.manifest.description}
|
||
</div>
|
||
) : null}
|
||
<div className="plugin-loop-home__card-meta">
|
||
{p.manifest?.od?.taskKind ? (
|
||
<span>{p.manifest.od.taskKind}</span>
|
||
) : null}
|
||
{p.manifest?.od?.kind ? <span>· {p.manifest.od.kind}</span> : null}
|
||
</div>
|
||
{links.authorName || links.sourceUrl ? (
|
||
<div
|
||
className="plugin-loop-home__card-byline"
|
||
data-testid={`plugin-card-byline-${p.id}`}
|
||
>
|
||
{links.authorName ? (
|
||
<span className="plugin-loop-home__card-byline-author">
|
||
<CardAvatar
|
||
name={links.authorName}
|
||
avatarUrl={links.authorAvatarUrl}
|
||
/>
|
||
<span>by {links.authorName}</span>
|
||
</span>
|
||
) : null}
|
||
{links.sourceUrl ? (
|
||
<a
|
||
href={links.sourceUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="plugin-loop-home__card-byline-source"
|
||
title={`View source: ${links.sourceLabel}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Icon
|
||
name={p.sourceKind === 'github' ? 'github' : 'external-link'}
|
||
size={11}
|
||
/>
|
||
<span>{links.sourceLabel}</span>
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div className="plugin-loop-home__card-actions">
|
||
<button
|
||
type="button"
|
||
className="plugin-loop-home__card-details"
|
||
onClick={() => { trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'card_details', plugin_id: p.id }); openDetails(p); }}
|
||
aria-label={`View details for ${p.title}`}
|
||
data-testid={`view-details-${p.id}`}
|
||
title="View plugin details"
|
||
>
|
||
<Icon name="eye" size={12} />
|
||
<span>Details</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="plugin-loop-home__card-action"
|
||
onClick={() => { trackPluginLoopClick(analytics.track, { page_name: 'plugins', area: 'plugin_loop', element: 'card_use', plugin_id: p.id }); void usePlugin(p); }}
|
||
disabled={isPending || pendingApplyId !== null}
|
||
aria-busy={isPending ? 'true' : undefined}
|
||
data-testid={`use-example-${p.id}`}
|
||
>
|
||
{isPending
|
||
? 'Applying…'
|
||
: hasQuery
|
||
? isActive
|
||
? 'Reload example query'
|
||
: 'Use example query'
|
||
: isActive
|
||
? 'Plugin active'
|
||
: 'Use plugin'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
{detailsRecord ? (
|
||
<PluginDetailsModal
|
||
record={detailsRecord}
|
||
onClose={closeDetails}
|
||
onUse={(record) => void usePlugin(record)}
|
||
isApplying={pendingApplyId === detailsRecord.id}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface CardAvatarProps {
|
||
name: string;
|
||
avatarUrl: string | null;
|
||
}
|
||
|
||
function CardAvatar({ name, avatarUrl }: CardAvatarProps) {
|
||
// Same hide-on-error pattern as the modal avatar — keep failures
|
||
// silent so a renamed/missing github profile doesn't show a
|
||
// broken-image icon in the grid.
|
||
const [broken, setBroken] = useState(false);
|
||
if (avatarUrl && !broken) {
|
||
return (
|
||
<img
|
||
className="plugin-loop-home__card-avatar"
|
||
src={avatarUrl}
|
||
alt=""
|
||
loading="lazy"
|
||
referrerPolicy="no-referrer"
|
||
onError={() => setBroken(true)}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
className="plugin-loop-home__card-avatar plugin-loop-home__card-avatar--fallback"
|
||
aria-hidden
|
||
>
|
||
{authorInitials(name)}
|
||
</span>
|
||
);
|
||
}
|