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
@@ -51,7 +51,9 @@ export const ApifyConfig = () => {
<div className="grid gap-5">
{status && (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`} />
<div
className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.configured ? 'bg-green-500' : 'bg-duck-dark/20 dark:bg-foreground/20'}`}
/>
<span className="text-sm text-duck-dark dark:text-foreground">
{status.configured ? 'API token configured' : 'Not configured'}
</span>
@@ -70,7 +72,12 @@ export const ApifyConfig = () => {
/>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
Get your token at{' '}
<a href="https://console.apify.com/account/integrations" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.apify.com/account/integrations"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
console.apify.com/account/integrations
</a>
</span>
@@ -69,7 +69,9 @@ export const BrowserRelay = () => {
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
{' — '}
<a href="/browser" className="text-duck-teal underline">view tabs</a>
<a href="/browser" className="text-duck-teal underline">
view tabs
</a>
</p>
</div>
</div>
@@ -96,7 +98,9 @@ export const BrowserRelay = () => {
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
<li>
Open{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">
chrome://extensions
</code>{' '}
in Chrome
</li>
<li>
@@ -139,12 +143,7 @@ export const BrowserRelay = () => {
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => regenerate.mutate()}
disabled={regenerate.isPending}
>
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Regenerate
</Button>
@@ -166,8 +165,8 @@ export const BrowserRelay = () => {
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
the tab is connected. Then go to{' '}
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge
means the tab is connected. Then go to{' '}
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
/browser <ExternalLink className="h-3 w-3" />
</a>{' '}
@@ -189,10 +188,11 @@ type CredentialRowProps = {
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
<code className="flex-1 text-xs truncate">
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
</code>
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
<code className="flex-1 text-xs truncate">{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}</code>
<button
onClick={onCopy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
</button>
</div>
@@ -196,10 +196,7 @@ export const EmailAccounts = () => {
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
return (
<div
key={account.id}
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
>
<div key={account.id} className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="flex items-center gap-3">
<ProviderIcon provider={account.provider} />
<div className="min-w-0 flex-1">
@@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/projectcreate"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
@@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/apis/library"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
API Library
</a>
. Search for and enable each of these:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Gmail API</strong></li>
<li><strong>Google Calendar API</strong></li>
<li>
<strong>Gmail API</strong>
</li>
<li>
<strong>Google Calendar API</strong>
</li>
</ul>
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/branding"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Set <strong>App name</strong> to your organization name or &quot;Officer&quot;</li>
<li>Set <strong>User support email</strong> to your admin email</li>
<li>Add your admin email under <strong>Developer contact information</strong></li>
<li>Click <strong>Save</strong></li>
<li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
</li>
<li>
Set <strong>User support email</strong> to your admin email
</li>
<li>
Add your admin email under <strong>Developer contact information</strong>
</li>
<li>
Click <strong>Save</strong>
</li>
</ul>
</li>
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
(required while the app is unverified; limit of 100 test users)
</li>
</ul>
</li>
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/scopes"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
@@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.scope}
</code>{' '}
— {s.description}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.description}
</li>
))}
</ul>
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
<p className="mt-1">
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li>
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/clients"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Application type: <strong>Web application</strong></li>
<li>
Application type: <strong>Web application</strong>
</li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>
Authorized redirect URIs: add{' '}
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
{redirectUri}
</code>
</li>
<li>Click <strong>Create</strong></li>
<li>
Click <strong>Create</strong>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
</li>
</ol>
@@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
</span>
</div>
);
@@ -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>
);
@@ -466,7 +466,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(provider.providerId) || 'Enter API key'}
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null);
@@ -496,7 +498,8 @@ export const AIHarnessesSection = () => {
{editingProvider &&
(() => {
const provider = CHAT_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId)) return null;
if (!provider || connectedProviders.some((cp) => cp.providerId === provider.providerId))
return null;
return (
<div key={provider.providerId} className="flex items-center gap-2">
<label className="w-36 text-duck-dark/70 dark:text-foreground/70 shrink-0 truncate font-medium text-[11px]">
@@ -507,7 +510,9 @@ export const AIHarnessesSection = () => {
className="h-7 text-xs flex-1"
placeholder="Enter API key"
value={keyInputs[provider.providerId] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))}
onChange={(ev) =>
setKeyInputs((prev) => ({ ...prev, [provider.providerId]: ev.target.value }))
}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && keyInputs[provider.providerId]) saveApiKey(provider.providerId);
if (ev.key === 'Escape') setEditingProvider(null);
@@ -81,9 +81,7 @@ export const SMTPSection = () => {
provider,
fromName,
fromEmail,
...(provider === 'resend'
? { apiKey }
: { host, port: parseInt(port) || 587, username, password, secure }),
...(provider === 'resend' ? { apiKey } : { host, port: parseInt(port) || 587, username, password, secure }),
});
const handleSave = async () => {
@@ -109,7 +107,11 @@ export const SMTPSection = () => {
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Connection failed';
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTestingConnection(false);
@@ -120,7 +122,10 @@ export const SMTPSection = () => {
if (isTesting || !testEmail) return;
setIsTesting(true);
try {
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', { ...buildConfig(), to: testEmail });
const result = await client.post<{ success?: boolean; error?: string }>('/server-settings/smtp/test', {
...buildConfig(),
to: testEmail,
});
if (result.error) {
toast.error(result.error);
} else {
@@ -129,7 +134,11 @@ export const SMTPSection = () => {
} catch (err: unknown) {
const raw = (err as { message?: string })?.message;
let msg = 'Failed to send test email';
try { if (raw) msg = JSON.parse(raw).error ?? msg; } catch { /* ignore */ }
try {
if (raw) msg = JSON.parse(raw).error ?? msg;
} catch {
/* ignore */
}
toast.error(msg);
} finally {
setIsTesting(false);
@@ -5,7 +5,15 @@ import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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';
type Provider = 'openai' | 'elevenlabs';
@@ -47,7 +55,11 @@ export const TTSSection = () => {
const fetchVoices = async (p: Provider, u: string, key: string, m?: string) => {
setVoicesLoading(true);
try {
const res = await client.post<{ voices?: string[]; groups?: { label: string; voices: string[] }[]; error?: string }>('/server-settings/tts/voices', {
const res = await client.post<{
voices?: string[];
groups?: { label: string; voices: string[] }[];
error?: string;
}>('/server-settings/tts/voices', {
provider: p,
url: u,
apiKey: key || undefined,
@@ -167,7 +179,9 @@ export const TTSSection = () => {
)}
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">API Key {provider === 'openai' ? '(optional)' : ''}</span>
<span className="text-duck-dark/70 dark:text-foreground/70">
API Key {provider === 'openai' ? '(optional)' : ''}
</span>
<Input
type="password"
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark dark:text-foreground placeholder:text-duck-dark/40"
@@ -197,7 +211,9 @@ export const TTSSection = () => {
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 ${voicesLoading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-3 w-3 text-duck-dark/50 dark:text-foreground/50 ${voicesLoading ? 'animate-spin' : ''}`}
/>
</button>
</div>
{voices.length > 0 ? (
@@ -209,16 +225,21 @@ export const TTSSection = () => {
{voiceGroups.length > 0
? voiceGroups.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}>{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}</SelectItem>
<SelectItem key={v} value={v}>
{v.replace(/^[a-z]{2}_/, '').replace(/^\w/, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectGroup>
))
: voices.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))
}
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
@@ -37,13 +37,17 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
isActive
? 'bg-duck-teal/10 text-duck-dark dark:text-foreground'
: 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<section.icon
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
@@ -53,7 +57,14 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
</NavLink>
);
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
export const SettingsSidebar = ({
basePath,
icon: Icon,
label,
sections,
groups,
hideHeader,
}: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const query = search.toLowerCase();
@@ -71,7 +82,12 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div>
)}
<div className="px-3 pb-2">
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
<Input
placeholder="Search..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
{groups
@@ -92,9 +108,9 @@ export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups,
</div>
);
})
: sections.filter(matchesSearch).map((s) => (
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
: sections
.filter(matchesSearch)
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
</div>
</div>
);
@@ -155,10 +171,22 @@ type CreateSettingsPanelParams = {
groups?: SettingsSectionGroup[];
};
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
export const createSettingsPanelComponents = ({
basePath,
sidebarIcon,
sidebarLabel,
sections = [],
groups,
}: CreateSettingsPanelParams) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const Sidebar: ComponentType = () => (
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
<SettingsSidebar
basePath={basePath}
icon={sidebarIcon}
label={sidebarLabel}
sections={allSections}
groups={groups}
/>
);
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
return { Sidebar, Content, allSections };