Files
platform/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx
T
pastilhasandClaude Opus 5 543e88a9a6 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>
2026-08-14 23:47:48 +00:00

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