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>
175 lines
5.9 KiB
TypeScript
175 lines
5.9 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Globe, RefreshCw, Send, Terminal, Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useBrowserTargets, useSelectedTabId } from './shared';
|
|
|
|
export const TabPreview = () => {
|
|
const client = useClient();
|
|
// Reads the URL itself rather than being told by the list.
|
|
const selectedId = useSelectedTabId();
|
|
const { data: targets } = useBrowserTargets(!!selectedId);
|
|
// A target id dies with the browser session, so a link or a reload can name a tab that is no longer
|
|
// attached. Screenshotting it would 404 every ten seconds; better to say so once.
|
|
const isKnownTab = !!selectedId && !!targets?.some((t) => t.id === selectedId);
|
|
const [navUrl, setNavUrl] = useState('');
|
|
const [evalExpr, setEvalExpr] = useState('');
|
|
const [evalResult, setEvalResult] = useState<string | null>(null);
|
|
const [isNavigating, setIsNavigating] = useState(false);
|
|
const [isEvaluating, setIsEvaluating] = useState(false);
|
|
|
|
const {
|
|
data: screenshot,
|
|
isLoading,
|
|
refetch,
|
|
isFetching,
|
|
} = useQuery({
|
|
queryKey: ['browser-screenshot', selectedId],
|
|
queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`),
|
|
enabled: isKnownTab,
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
const handleNavigate = useCallback(async () => {
|
|
if (!selectedId || !navUrl.trim()) return;
|
|
setIsNavigating(true);
|
|
try {
|
|
await client.post(`/browser/targets/${selectedId}/navigate`, { url: navUrl.trim() });
|
|
setTimeout(() => void refetch(), 1000);
|
|
} catch {
|
|
toast.error('Navigation failed');
|
|
} finally {
|
|
setIsNavigating(false);
|
|
}
|
|
}, [client, selectedId, navUrl, refetch]);
|
|
|
|
const handleEvaluate = useCallback(async () => {
|
|
if (!selectedId || !evalExpr.trim()) return;
|
|
setIsEvaluating(true);
|
|
try {
|
|
const res = await client.post<{ result: unknown }>(`/browser/targets/${selectedId}/evaluate`, {
|
|
expression: evalExpr.trim(),
|
|
});
|
|
setEvalResult(JSON.stringify(res.result, null, 2));
|
|
} catch (err) {
|
|
setEvalResult(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
} finally {
|
|
setIsEvaluating(false);
|
|
}
|
|
}, [client, selectedId, evalExpr]);
|
|
|
|
if (!selectedId) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
|
<Globe className="h-8 w-8" />
|
|
<span>Select a tab to preview</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Waiting on the list is not the same as knowing the tab is gone, so only say so once it has arrived.
|
|
if (targets && !isKnownTab) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
|
<Globe className="h-8 w-8" />
|
|
<span>That tab is no longer attached</span>
|
|
<Link to="/browser" className="text-xs underline">
|
|
Back to the tab list
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
{/* URL bar */}
|
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 shrink-0"
|
|
onClick={() => void refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<form
|
|
className="flex flex-1 items-center gap-2"
|
|
onSubmit={(ev) => {
|
|
ev.preventDefault();
|
|
void handleNavigate();
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={navUrl}
|
|
onChange={(ev) => setNavUrl(ev.target.value)}
|
|
placeholder="Navigate to URL..."
|
|
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 shrink-0"
|
|
type="submit"
|
|
disabled={isNavigating || !navUrl.trim()}
|
|
>
|
|
<Send className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Screenshot */}
|
|
<div className="flex-1 overflow-auto p-2">
|
|
{isLoading && !screenshot && (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin opacity-50" />
|
|
</div>
|
|
)}
|
|
{screenshot && (
|
|
<img
|
|
src={`data:image/${screenshot.format};base64,${screenshot.data}`}
|
|
alt="Tab screenshot"
|
|
className="w-full rounded-md border"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* JS Console */}
|
|
<div className="border-t">
|
|
<form
|
|
className="flex items-center gap-2 px-3 py-2"
|
|
onSubmit={(ev) => {
|
|
ev.preventDefault();
|
|
void handleEvaluate();
|
|
}}
|
|
>
|
|
<Terminal className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
<input
|
|
type="text"
|
|
value={evalExpr}
|
|
onChange={(ev) => setEvalExpr(ev.target.value)}
|
|
placeholder="Evaluate JavaScript..."
|
|
className="flex-1 bg-transparent text-sm outline-none font-mono"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 shrink-0"
|
|
type="submit"
|
|
disabled={isEvaluating || !evalExpr.trim()}
|
|
>
|
|
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
|
|
</Button>
|
|
</form>
|
|
{evalResult !== null && (
|
|
<pre className="max-h-32 overflow-auto border-t px-3 py-2 text-xs font-mono opacity-70">{evalResult}</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|