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:
@@ -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>
|
||||
|
||||
|
||||
+38
-11
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user