browser relay: multi-session override, stale session recovery, better error feedback
- relay accepts new extension connections by closing old one (last wins, code 4000) - extension recognizes code 4000 and stops auto-reconnect (shows "replaced" badge) - fix ping interval race where old WS close handler killed new connection's pings - retry CDP commands on "Session with given id not found" by re-attaching debugger - describeError() maps known errors to user-friendly badge tooltips - persisted relay tokens restored on server start - user-scoped token salts, token regeneration/deletion endpoints - extension download endpoint, integrations UI for browser relay setup - browser relay env vars passed to pi-bridge sandboxes - browser screen layout with chat panel and prompt prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+198
@@ -0,0 +1,198 @@
|
||||
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';
|
||||
|
||||
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 navigator.clipboard.writeText(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="https://static.officer.dev/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>
|
||||
);
|
||||
Reference in New Issue
Block a user