put the previewed browser tab in the url

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 11:56:45 +00:00
co-authored by Claude Opus 5
parent 6c47cbeb74
commit 374140d3a6
7 changed files with 124 additions and 61 deletions
+1
View File
@@ -95,6 +95,7 @@ export function App() {
<Route path="/email" element={<Dashboard.EmailScreen />} />
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
<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 />} />
@@ -1,12 +1,13 @@
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 { useGlobal } from 'hooks/useGlobal';
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.`;
@@ -15,7 +16,10 @@ const BrowserChatPanel = () => <ChatPanelWrapper panelId="browser-chat" promptPr
export const BrowserScreen = () => {
const isMobile = useIsMobile();
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
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(
@@ -28,7 +32,7 @@ export const BrowserScreen = () => {
);
const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined;
const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]);
const onMobileBack = useCallback(() => navigate('/browser'), [navigate]);
return (
<div className="h-full w-full pt-2">
@@ -1,20 +1,13 @@
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 { useGlobal } from 'hooks/useGlobal';
type Target = {
id: string;
sessionId: string;
type: string;
title: string;
url: string;
};
import { browserTabPath, useBrowserTargets, useSelectedTabId, type BrowserTarget } from './shared';
export const TabList = () => {
const client = useClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
const selectedId = useSelectedTabId();
const { data: status } = useQuery({
queryKey: ['browser-status'],
@@ -22,14 +15,9 @@ export const TabList = () => {
refetchInterval: 5000,
});
const { data: targets, isLoading } = useQuery({
queryKey: ['browser-targets'],
queryFn: () => client.get<Target[]>('/browser/targets'),
refetchInterval: 3000,
enabled: !!status?.extensionConnected,
});
const { data: targets, isLoading } = useBrowserTargets(!!status?.extensionConnected);
const targetList: Target[] = targets ?? [];
const targetList: BrowserTarget[] = targets ?? [];
const handleClose = async (targetId: string) => {
try {
@@ -64,12 +52,10 @@ export const TabList = () => {
<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
href="/settings/integrations"
className="text-xs text-duck-teal underline"
>
{/* 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
</a>
</Link>
</div>
)}
@@ -89,41 +75,40 @@ export const TabList = () => {
{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) => (
<button
<div
key={target.id}
onClick={() => setSelectedId(target.id)}
className={`group flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
className={`group relative flex transition-colors ${
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{target.title || 'Untitled'}</span>
<div className="ml-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(ev) => {
ev.stopPropagation();
handleActivate(target.id);
}}
className="rounded p-0.5 hover:bg-black/10"
title="Focus tab"
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={(ev) => {
ev.stopPropagation();
handleClose(target.id);
}}
className="rounded p-0.5 hover:bg-black/10"
title="Close tab"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<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>
<span className="truncate text-xs opacity-50">{target.url}</span>
</button>
</div>
))}
</div>
)}
@@ -1,14 +1,20 @@
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 { useGlobal } from 'hooks/useGlobal';
import { useBrowserTargets, useSelectedTabId } from './shared';
export const TabPreview = () => {
const client = useClient();
const [selectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
// 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);
@@ -23,7 +29,7 @@ export const TabPreview = () => {
} = useQuery({
queryKey: ['browser-screenshot', selectedId],
queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`),
enabled: !!selectedId,
enabled: isKnownTab,
refetchInterval: 10000,
});
@@ -64,6 +70,19 @@ export const TabPreview = () => {
);
}
// 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 */}
@@ -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,
});
};