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
@@ -87,19 +87,25 @@ export const AIModels = () => {
<div className="grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Chat Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat from the home screen</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat from the home screen
</p>
{renderModelSelect(chatModel, setChatModel, 'System default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when starting a new chat inside a project dashboard
</p>
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Default Task Model</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when running tasks from the file browser</p>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Used when running tasks from the file browser
</p>
{renderModelSelect(taskModel, setTaskModel, 'Same as chat default')}
</Label>
@@ -1,7 +1,15 @@
import { useState, useEffect, useRef } from 'react';
import { RefreshCw, Play, Square, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClient } from 'hooks/useClient';
import { useSettings } from 'state/useSettings';
@@ -96,8 +104,16 @@ export const VoicePreference = () => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onerror = () => { audioRef.current = null; setListening('idle'); URL.revokeObjectURL(url); };
audio.onended = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
audio.onerror = () => {
audioRef.current = null;
setListening('idle');
URL.revokeObjectURL(url);
};
await audio.play();
setListening('playing');
} catch {
@@ -120,7 +136,9 @@ export const VoicePreference = () => {
className="p-0.5 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors disabled:opacity-40"
title="Refresh voices"
>
<RefreshCw className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
<div className="flex items-center gap-2">
@@ -129,20 +147,27 @@ export const VoicePreference = () => {
<SelectValue placeholder="Server default" />
</SelectTrigger>
<SelectContent className="z-[600] max-h-[300px]">
<SelectItem value={SERVER_DEFAULT}>Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}</SelectItem>
<SelectItem value={SERVER_DEFAULT}>
Server default{serverConfig?.voice ? ` (${prettify(serverConfig.voice)})` : ''}
</SelectItem>
{groups.length > 0
? groups.map((g) => (
<SelectGroup key={g.label}>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">{g.label}</SelectLabel>
<SelectLabel className="text-xs font-semibold text-duck-dark/50 dark:text-foreground/50">
{g.label}
</SelectLabel>
{g.voices.map((v) => (
<SelectItem key={v} value={v}>{prettify(v)}</SelectItem>
<SelectItem key={v} value={v}>
{prettify(v)}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
<button
@@ -161,7 +186,9 @@ export const VoicePreference = () => {
)}
</button>
</div>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Choose a voice for text-to-speech. Leave as server default to use the admin-configured voice.
</span>
</Label>
</div>
);