every plugin route renders a workspace, and it is not a rule you can forget

an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:47:48 +00:00
co-authored by Claude Opus 5
parent 2e3c935da6
commit 543e88a9a6
38 changed files with 1026 additions and 440 deletions
@@ -14,7 +14,17 @@ export const useComposer = () => useGlobal<ComposeDraft | null>('EMAIL_COMPOSE',
type Contact = { address: string; name: string };
// A recipient field with contact autocomplete on the last comma-separated segment.
const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: string; onChange: (v: string) => void; placeholder: string; autoFocus?: boolean }) => {
const RecipientInput = ({
value,
onChange,
placeholder,
autoFocus,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
autoFocus?: boolean;
}) => {
const client = useClient();
const [suggestions, setSuggestions] = useState<Contact[]>([]);
const [open, setOpen] = useState(false);
@@ -26,7 +36,10 @@ const RecipientInput = ({ value, onChange, placeholder, autoFocus }: { value: st
return;
}
const t = setTimeout(() => {
client.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`).then(setSuggestions).catch(() => {});
client
.get<Contact[]>(`/email/contacts?q=${encodeURIComponent(seg)}`)
.then(setSuggestions)
.catch(() => {});
}, 180);
return () => clearTimeout(t);
}, [seg]);
@@ -122,14 +135,16 @@ export const ComposeModal = () => {
inlineMap.current.clear();
nextImgId.current = 0;
// Seed the contenteditable body directly (uncontrolled — React never re-renders its content).
if (editorRef.current) editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
if (editorRef.current)
editorRef.current.innerHTML = draft.body ? escapeHtml(draft.body).replace(/\n/g, '<br>') : '';
refreshEmpty();
}
if (!draft) seeded.current = null;
}, [draft]);
// Clipboard images often come nameless — give them a sensible filename.
const named = (f: File) => (f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type }));
const named = (f: File) =>
f.name ? f : new File([f], `pasted-${Date.now()}.${f.type.split('/')[1] || 'png'}`, { type: f.type });
// Attach button (and non-image paste/drop): everything goes as a regular attachment.
const addAttachments = (incoming: FileList | File[]) => {
@@ -248,7 +263,8 @@ export const ComposeModal = () => {
}
};
const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
const fmtSize = (n: number) =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
if (!draft) return null;
@@ -315,7 +331,9 @@ export const ComposeModal = () => {
className="border-b bg-transparent px-4 py-2 text-sm outline-none placeholder:opacity-40"
/>
<div className="relative flex-1 overflow-hidden">
{bodyEmpty && <div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message</div>}
{bodyEmpty && (
<div className="pointer-events-none absolute left-4 top-3 text-sm opacity-40">Write your message</div>
)}
<div
ref={editorRef}
contentEditable
@@ -362,7 +380,10 @@ export const ComposeModal = () => {
>
<Paperclip className="h-4 w-4" />
</button>
<button onClick={close} className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer">
<button
onClick={close}
className="rounded-md px-4 py-1.5 text-sm opacity-60 hover:opacity-100 cursor-pointer"
>
Cancel
</button>
<button
@@ -382,12 +403,21 @@ export const ComposeModal = () => {
// Build a reply draft from a viewed message. (No In-Reply-To yet — the real RFC Message-ID isn't
// stored; `m.id` is a local hash. Gmail still threads by Re: subject + participants. Proper threading
// is a follow-up: store the Message-Id header on ingest.)
export const replyDraft = (m: { from: string; subject: string; date: string; text?: string; snippet?: string }): ComposeDraft => {
export const replyDraft = (m: {
from: string;
subject: string;
date: string;
text?: string;
snippet?: string;
}): ComposeDraft => {
const addr = m.from.match(/<([^>]+)>/)?.[1] ?? m.from.trim();
const subject = /^re:/i.test(m.subject) ? m.subject : `Re: ${m.subject}`;
const original = (m.text || m.snippet || '').trim();
const quoted = original
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original.split('\n').map((l) => `> ${l}`).join('\n')}`
? `\n\nOn ${new Date(m.date).toLocaleString()}, ${m.from} wrote:\n${original
.split('\n')
.map((l) => `> ${l}`)
.join('\n')}`
: '';
return { to: addr, subject, body: quoted };
};