browser, extracted from the platform — extension, relay and all
Your own Chrome, driven from Officer. Not a headless one: it attaches to the browser you are already
signed into, which is the whole difference from /scrape, and /scrape stays behind in the platform.
The relay is a sidecar now, and that is the point. It was a second Bun.serve inside the platform
process on its own port, which is exactly why it was switched off on 2026-08-13 — "the platform serves
one port". A sidecar is a process allowed its own listeners, so the objection stops applying instead of
being argued with.
The boundary across it is deliberately small: two questions, who is connected and what tabs they have,
because only those live in memory on the relay side. Screenshots, evaluation and navigation still go
straight down cdp.ts's own WebSocket to the relay's /cdp endpoint, exactly as before — routing page
bytes through the platform would be work for nothing.
Three normally-breaking changes taken now because now they are free, each already recommended in a
comment by whoever switched the relay off. Its own signing key instead of borrowing getKey('jwt'). Its
own table instead of the platform's user_integrations. And the port kept at 18792 on purpose, because
the port is an input to the token HMAC as well as an address — moving it fails every pairing silently,
and the extension blames the address. user_integrations held zero browser-relay rows, checked in
Postgres rather than assumed, so there was nothing to unpair.
The screen became panels. BrowserScreen.tsx rendered TabList and TabPreview through appType: null
placeholders; both components were already self-contained and took no props, so the screen had nothing
to pass and simply went.
Not tested end to end — no browser has been paired since the move, and that needs a Chrome and a
person. BLOCKERS.md says so rather than the commit implying otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router';
|
||||
import { Globe, Loader2, X, Eye } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { browserTabPath, useBrowserTargets, useSelectedTabId, type BrowserTarget } from './shared';
|
||||
|
||||
export const TabList = () => {
|
||||
const client = useClient();
|
||||
const selectedId = useSelectedTabId();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['browser-status'],
|
||||
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: targets, isLoading } = useBrowserTargets(!!status?.extensionConnected);
|
||||
|
||||
const targetList: BrowserTarget[] = targets ?? [];
|
||||
|
||||
const handleClose = async (targetId: string) => {
|
||||
try {
|
||||
await client.post(`/browser/targets/${targetId}/close`);
|
||||
} catch {
|
||||
toast.error('Failed to close tab');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (targetId: string) => {
|
||||
try {
|
||||
await client.post(`/browser/targets/${targetId}/activate`);
|
||||
} catch {
|
||||
toast.error('Failed to activate tab');
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = status?.extensionConnected ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Globe className="h-4 w-4 opacity-60" />
|
||||
<span className="text-sm font-medium">Browser</span>
|
||||
<div
|
||||
className={`ml-auto h-2 w-2 rounded-full ${isConnected ? 'bg-success' : 'bg-red-500'}`}
|
||||
title={isConnected ? 'Extension connected' : 'Extension not connected'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 p-4 text-sm flex-1">
|
||||
<Globe className="h-8 w-8 opacity-30" />
|
||||
<p className="opacity-60 text-center">Extension not connected</p>
|
||||
{/* A <Link>, not an <a href>: a raw href to an in-app route reloads the whole SPA. */}
|
||||
<Link to="/settings/integrations" className="text-xs text-duck-teal underline">
|
||||
Set up in Integrations
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && isLoading && (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && !isLoading && targetList.length === 0 && (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Globe className="h-6 w-6" />
|
||||
<span>No tabs attached</span>
|
||||
<span className="text-xs">Click the extension icon on a tab</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && targetList.length > 0 && (
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10">
|
||||
{/* Focus and Close are siblings of the anchor, not children of it: an <a> may not contain a
|
||||
<button>, and the old row was a <button> containing two more — invalid either way, and it
|
||||
was only the stopPropagation calls holding it together. */}
|
||||
{targetList.map((target) => (
|
||||
<div
|
||||
key={target.id}
|
||||
className={`group relative flex transition-colors ${
|
||||
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
to={browserTabPath(target.id)}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2.5 text-left"
|
||||
>
|
||||
<span className="truncate pr-14 text-sm font-medium">{target.title || 'Untitled'}</span>
|
||||
<span className="truncate text-xs opacity-50">{target.url}</span>
|
||||
</Link>
|
||||
<div className="absolute right-3 top-2.5 flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => handleActivate(target.id)}
|
||||
className="rounded p-0.5 hover:bg-black/10 cursor-pointer"
|
||||
title="Focus tab"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClose(target.id)}
|
||||
className="rounded p-0.5 hover:bg-black/10 cursor-pointer"
|
||||
title="Close tab"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// The tab list beside a preview, with chat underneath it — the shape the screen had before the
|
||||
// extraction, with the `appType: null` placeholders replaced by the panels' real keys.
|
||||
//
|
||||
// Chat is deliberately still here. It is the platform's own panel, not this plugin's, and the point of
|
||||
// the layout is driving a browser while talking to an agent about what is on it.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'browser-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-tabs', appType: 'officerdev/browser-tabs' }, size: 25 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'browser-right',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-preview', appType: 'officerdev/browser-preview' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'browser-chat', appType: 'officerdev/chat' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 75,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Globe, LayoutList } from 'lucide-react';
|
||||
import { TabList } from './TabList';
|
||||
import { TabPreview } from './TabPreview';
|
||||
|
||||
// Two panels, both self-contained — they read their own data and take no props, which is why the screen
|
||||
// that used to render them (`BrowserScreen.tsx`, with `appType: null` placeholders) had nothing to pass
|
||||
// and could simply be deleted. The Workspace hosts them directly now.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/browser-tabs',
|
||||
name: 'Tabs',
|
||||
icon: LayoutList,
|
||||
component: TabList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'officerdev/browser-preview',
|
||||
name: 'Preview',
|
||||
icon: Globe,
|
||||
component: TabPreview,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Which attached tab is being previewed is `/browser/:tabId`. It used to be a `BROWSER_SELECTED_TAB`
|
||||
// global read by three components, which meant the address bar said `/browser` no matter what you were
|
||||
// looking at, and a tab row was a `<button>` with the id in its onClick closure.
|
||||
|
||||
export type BrowserTarget = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const browserTabPath = (id: string) => `/browser/${encodeURIComponent(id)}`;
|
||||
|
||||
/** The previewed tab, or null on the bare `/browser` route — the list with nothing open. */
|
||||
export const useSelectedTabId = (): string | null => useParams<{ tabId?: string }>().tabId ?? null;
|
||||
|
||||
// Both panels want this list and React Query serves both observers from one key, so the preview being
|
||||
// able to tell "no tab open" from "that tab is gone" costs no extra request. It needs to: a CDP target
|
||||
// id dies with the browser session, and now that the id is in the URL a reload can carry a stale one in
|
||||
// — which the old global, living in memory, could never do.
|
||||
export const useBrowserTargets = (enabled: boolean) => {
|
||||
const client = useClient();
|
||||
return useQuery({
|
||||
queryKey: ['browser-targets'],
|
||||
queryFn: () => client.get<BrowserTarget[]>('/browser/targets'),
|
||||
refetchInterval: 3000,
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user