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>
200 lines
7.6 KiB
TypeScript
200 lines
7.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { copyToClipboard } from 'helpers/clipboard';
|
|
|
|
type RelayToken = {
|
|
token: string;
|
|
port: number;
|
|
};
|
|
|
|
export const BrowserRelay = () => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
|
|
|
const { data: status } = useQuery({
|
|
queryKey: ['browser-status'],
|
|
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
|
|
refetchInterval: 5000,
|
|
});
|
|
|
|
const { data: tokenData } = useQuery({
|
|
queryKey: ['browser-relay-token'],
|
|
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
|
|
});
|
|
|
|
const regenerate = useMutation({
|
|
mutationFn: () => client.post<RelayToken>('/browser/relay-token/regenerate'),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
|
|
toast.success('Token regenerated — update the extension with the new token');
|
|
},
|
|
onError: () => toast.error('Failed to regenerate token'),
|
|
});
|
|
|
|
const revoke = useMutation({
|
|
mutationFn: () => client.delete('/browser/relay-token'),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
|
|
toast.success('Token revoked');
|
|
},
|
|
onError: () => toast.error('Failed to revoke token'),
|
|
});
|
|
|
|
const handleCopy = async (value: string, field: string) => {
|
|
try {
|
|
await copyToClipboard(value);
|
|
setCopiedField(field);
|
|
toast.success('Copied to clipboard');
|
|
setTimeout(() => setCopiedField(null), 2000);
|
|
} catch {
|
|
toast.error('Failed to copy');
|
|
}
|
|
};
|
|
|
|
const isConnected = status?.extensionConnected ?? false;
|
|
const serverAddress = window.location.hostname;
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
{isConnected ? (
|
|
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
|
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
|
<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>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
|
Connect your Chrome browser to Officer so AI agents can view and interact with your tabs.
|
|
</p>
|
|
)}
|
|
|
|
{/* Step 1: Install */}
|
|
<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">1. Install the extension</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
Download and unzip the extension, then load it in Chrome:
|
|
</p>
|
|
<a
|
|
href="/browser-relay-extension.zip"
|
|
download
|
|
className="flex items-center justify-center gap-2 w-full h-11 rounded-md border border-duck-dark/15 dark:border-foreground/15 text-sm font-medium hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download Extension
|
|
</a>
|
|
<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>{' '}
|
|
in Chrome
|
|
</li>
|
|
<li>
|
|
Enable <strong>Developer mode</strong> (top-right toggle)
|
|
</li>
|
|
<li>
|
|
Click <strong>Load unpacked</strong> and select the unzipped folder
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
|
|
{/* Step 2: Configure */}
|
|
{tokenData && (
|
|
<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">2. Configure the extension</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
Open the extension options (right-click icon → Options) and enter these values:
|
|
</p>
|
|
|
|
<div className="grid gap-2">
|
|
<CredentialRow
|
|
label="Server address"
|
|
value={serverAddress}
|
|
copied={copiedField === 'host'}
|
|
onCopy={() => handleCopy(serverAddress, 'host')}
|
|
/>
|
|
<CredentialRow
|
|
label="Port"
|
|
value={String(tokenData.port)}
|
|
copied={copiedField === 'port'}
|
|
onCopy={() => handleCopy(String(tokenData.port), 'port')}
|
|
/>
|
|
<CredentialRow
|
|
label="Relay token"
|
|
value={tokenData.token}
|
|
masked
|
|
copied={copiedField === 'token'}
|
|
onCopy={() => handleCopy(tokenData.token, 'token')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => regenerate.mutate()} disabled={regenerate.isPending}>
|
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
|
Regenerate
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => revoke.mutate()}
|
|
disabled={revoke.isPending}
|
|
className="text-red-500 hover:text-red-600"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
Revoke
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: Attach */}
|
|
<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{' '}
|
|
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
|
|
/browser <ExternalLink className="h-3 w-3" />
|
|
</a>{' '}
|
|
to view your tabs.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type CredentialRowProps = {
|
|
label: string;
|
|
value: string;
|
|
masked?: boolean;
|
|
copied: boolean;
|
|
onCopy: () => void;
|
|
};
|
|
|
|
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"
|
|
>
|
|
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
|
|
</button>
|
|
</div>
|
|
);
|