put the previewed browser tab in the url
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user