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>
);