the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history, and nothing regressed, because none of it had been running. What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so the registry stops describing one thing in the other's words, and it drops the /browser frontend route it could no longer serve. Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering panels through appType placeholders — a plugin supplies its own route and panels through Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no longer had: dead at runtime, invisible to the compiler. The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where it lives now. It was written for whoever did this extraction, and this is that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,10 +120,7 @@ export function App() {
|
||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/browser/:tabId" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/desktop" element={<Dashboard.DesktopScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { TabList } from './TabList';
|
||||
import { TabPreview } from './TabPreview';
|
||||
import { useSelectedTabId } from './shared';
|
||||
|
||||
const PROMPT_PREFIX = `You are a browser assistant. The user has connected Chrome tabs via the Officer Browser Relay. You have a 'browser' tool — use it to list tabs, take screenshots, navigate, evaluate JavaScript, and get page info. When asked about a page, take a screenshot first.`;
|
||||
|
||||
// This screen's prompt, so this screen's chat panel. See the note in `EmailScreen`.
|
||||
const BrowserChatPanel = () => <ChatPanelWrapper panelId="browser-chat" promptPrefix={PROMPT_PREFIX} />;
|
||||
|
||||
export const BrowserScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
// No redirect guard: like /email, the bare /browser is the list with nothing open, and a tab id that
|
||||
// no longer resolves gets the preview's "no longer attached" state rather than a rewritten address.
|
||||
const selectedId = useSelectedTabId();
|
||||
const workspace = useDashboardState<LayoutNode>('screens/browser', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'browser-tabs': TabList,
|
||||
'browser-preview': TabPreview,
|
||||
'browser-chat': BrowserChatPanel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined;
|
||||
const onMobileBack = useCallback(() => navigate('/browser'), [navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
components={components}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'browser-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'browser-right',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-preview', appType: null }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'browser-chat', appType: 'officerdev/chat' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 75,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { BrowserScreen } from './BrowserScreen';
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const DesktopScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/desktop', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['officerdev/desktop'], fallback: 'officerdev/desktop' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'desktop-screen',
|
||||
appType: 'officerdev/desktop',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './DesktopScreen';
|
||||
@@ -20,7 +20,5 @@ export * from './CodeEditor';
|
||||
export * from './ChatHistory';
|
||||
export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Browser';
|
||||
export * from './Desktop';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
|
||||
Reference in New Issue
Block a user