'use client'; // Host-rendered modal for plugin-requested confirm/alert/prompt dialogs. // Subscribes to the host-dialog queue and renders the head request, one at // a time. Closing the modal advances the queue. Prompts collect one or more // (optionally masked) fields so plugins never fall back to `window.prompt`, // which the sandbox blocks. import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; // Lightweight **bold** support in plugin dialog messages. Everything else is // rendered literally (newlines come from the parent's white-space: pre-wrap). // Splitting on the ** delimiter yields alternating plain/bold segments (odd // indices are bold). Plugins control these strings, so the delimiters balance. function renderMessage(message?: string): React.ReactNode { if (!message) return null; return message.split('**').map((seg, i) => i % 2 === 1 ? {seg} : {seg}, ); } export function PluginDialogHost(): React.JSX.Element | null { const current = useSyncExternalStore(subscribe, head, () => null); const isPrompt = current?.kind === 'prompt'; const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]); // Field values for a prompt, re-initialised whenever a new dialog surfaces. const [values, setValues] = useState>({}); useEffect(() => { if (!current) return; const init: Record = {}; for (const f of current.fields ?? []) init[f.name] = ''; setValues(init); }, [current]); const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0); const cancel = () => resolveHead(current?.kind === 'prompt' ? null : false); const submitPrompt = () => { if (canSubmit) resolveHead(values); }; useEffect(() => { if (!current) return; function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); cancel(); } else if (e.key === 'Enter' && !isPrompt) { // For prompts, Enter is handled by the form (so it respects required // validation and works from within an input); non-prompt dialogs accept. e.preventDefault(); resolveHead(true); } } document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [current, isPrompt, canSubmit, values]); if (!current) return null; const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : current.kind === 'prompt' ? 'Submit' : 'Confirm'); const cancelLabel = current.cancelLabel ?? 'Cancel'; const btnBase: React.CSSProperties = { padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer' }; const secondaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit' }; const primaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid transparent', background: 'var(--color-primary, #3b82f6)', color: 'var(--color-primary-foreground, #fff)' }; return ( { if (e.target === e.currentTarget) cancel(); }} > {current.title} {current.message && ( {renderMessage(current.message)} )} {isPrompt ? ( { e.preventDefault(); submitPrompt(); }}> {fields.map((f, i) => ( {f.label}{f.required ? ' *' : ''} setValues((v) => ({ ...v, [f.name]: e.target.value }))} style={{ fontSize: 13, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-input, var(--color-border, #cbd5e1))', background: 'var(--color-background, #fff)', color: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', }} /> ))} {cancelLabel} {confirmLabel} ) : ( {current.kind === 'confirm' && ( {cancelLabel} )} resolveHead(true)} style={{ ...primaryBtn, background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)', color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)', }} > {confirmLabel} )} From plugin: {current.pluginId} ); }
{renderMessage(current.message)}