diff --git a/apps/web/src/components/app/browser-extension-connections-card.tsx b/apps/web/src/components/app/browser-extension-connections-card.tsx new file mode 100644 index 00000000..bd9f7618 --- /dev/null +++ b/apps/web/src/components/app/browser-extension-connections-card.tsx @@ -0,0 +1,141 @@ +import { MonitorSmartphone, Unplug } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { formatDateTime, formatRelativeTime } from "@/lib/format"; + +import type { BrowserExtensionConnection } from "./use-extension-pairing"; + +const initiallyVisibleConnections = 5; + +type BrowserExtensionConnectionsCardProps = { + connections: BrowserExtensionConnection[]; + isPending: boolean; + isError: boolean; + onRetry: () => void; + showAllConnections: boolean; + setShowAllConnections: Dispatch>; + revokeIsPending: boolean; + revokeIsError: boolean; + onRevoke: (connectionId: string) => void; +}; + +export function BrowserExtensionConnectionsCard({ + connections, + isPending, + isError, + onRetry, + showAllConnections, + setShowAllConnections, + revokeIsPending, + revokeIsError, + onRevoke, +}: BrowserExtensionConnectionsCardProps): JSX.Element { + const visibleConnections = showAllConnections + ? connections + : connections.slice(0, initiallyVisibleConnections); + const hiddenConnectionCount = connections.length - visibleConnections.length; + + return ( + + + Paired browsers + + Each browser has its own access. Revoking one does not disconnect the + others. + + + + {isPending ? ( +

+ Loading paired browsers... +

+ ) : isError ? ( +
+

+ Could not load paired browsers. +

+ +
+ ) : connections.length === 0 ? ( +

+ Waiting for this browser to finish connecting. +

+ ) : ( +
+ {visibleConnections.map((connection) => ( +
+
+ ))} + {connections.length > initiallyVisibleConnections && ( + + )} +
+ )} + {revokeIsError && ( +

+ Could not revoke that browser. Try again. +

+ )} +
+
+ ); +} diff --git a/apps/web/src/components/app/browser-extension-pairing-card.tsx b/apps/web/src/components/app/browser-extension-pairing-card.tsx new file mode 100644 index 00000000..34f3c017 --- /dev/null +++ b/apps/web/src/components/app/browser-extension-pairing-card.tsx @@ -0,0 +1,147 @@ +import { ShieldCheck } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +import type { ApprovalState } from "./use-extension-pairing"; + +type BrowserExtensionPairingCardProps = { + approvalState: ApprovalState; + pairingRequestIsValid: boolean; + code: string | null; + error: string; + onApprove: () => void; + onCheckAgain: () => void; +}; + +export function BrowserExtensionPairingCard({ + approvalState, + pairingRequestIsValid, + code, + error, + onApprove, + onCheckAgain, +}: BrowserExtensionPairingCardProps): JSX.Element { + return ( + + +
+
+
+ + {approvalState === "connected" + ? "Browser connected" + : approvalState === "timedOut" + ? "Connection still pending" + : "Approve this browser"} + + + {approvalState === "connected" + ? "The extension is ready to send feedback to your agents." + : approvalState === "timedOut" + ? "Dispatch has not seen the browser finish connecting yet." + : "Finish the connection request you started in the extension."} + +
+
+ +
+ {approvalState === "connected" ? ( +
+
+ ) : !pairingRequestIsValid ? ( +

+ This browser extension pairing link is incomplete. Return to the + extension and start the connection again. +

+ ) : approvalState === "waiting" ? ( +
+
+ ) : approvalState === "timedOut" ? ( +
+
+

+ Browser has not finished connecting +

+

+ Keep the extension open while it finishes the exchange, then + check again. If the request is no longer visible in the + extension, start a new connection there. +

+
+ +
+ ) : ( +
+
+

+ Chrome is requesting permission to connect +

+

+ Approve only if you started this request. The extension will + be able to view available agents and send page feedback to the + agent you select. +

+
+
+

+ Confirm this code matches the extension +

+ + {code} + +
+ {approvalState === "error" && ( +

+ {error} +

+ )} + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/app/browser-extension-settings.tsx b/apps/web/src/components/app/browser-extension-settings.tsx index ad73323a..55de4b3c 100644 --- a/apps/web/src/components/app/browser-extension-settings.tsx +++ b/apps/web/src/components/app/browser-extension-settings.tsx @@ -1,154 +1,31 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; -import { - Check, - ChevronDown, - ChevronUp, - Chrome, - Copy, - Download, - FolderOpen, - MonitorSmartphone, - Plus, - Puzzle, - ShieldCheck, - Unplug, -} from "lucide-react"; -import { useSearchParams } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { useCopyText } from "@/hooks/use-copy"; -import { api } from "@/lib/api"; -import { formatDateTime, formatRelativeTime } from "@/lib/format"; - -type ApprovalState = - | "idle" - | "approving" - | "waiting" - | "timedOut" - | "connected" - | "error"; -type BrowserExtensionConnection = { - id: string; - deviceName: string; - createdAt: string; - expiresAt: string; - lastUsedAt: string | null; -}; - -const connectionsQueryKey = ["browser-extension", "connections"] as const; -const postApprovalRefreshAttempts = 21; -const postApprovalRefreshDelayMs = 500; -const initiallyVisibleConnections = 5; +import { BrowserExtensionConnectionsCard } from "./browser-extension-connections-card"; +import { BrowserExtensionPairingCard } from "./browser-extension-pairing-card"; +import { BrowserExtensionSetupCard } from "./browser-extension-setup-card"; +import { + useExtensionConnections, + useExtensionPairing, +} from "./use-extension-pairing"; export function BrowserExtensionSettings(): JSX.Element { - const queryClient = useQueryClient(); - const [searchParams, setSearchParams] = useSearchParams(); - const pairingId = searchParams.get("browserExtensionPairing"); - const code = searchParams.get("code"); - const hasPairingRequest = pairingId !== null || code !== null; - const pairingRequestIsValid = Boolean(pairingId && code); - const [approvalState, setApprovalState] = useState("idle"); - const [error, setError] = useState(""); + const { connectionsQuery, revokeMutation } = useExtensionConnections(); + const { + code, + hasPairingRequest, + pairingRequestIsValid, + approvalState, + setApprovalState, + error, + approvePairing, + } = useExtensionPairing(connectionsQuery); const [showInstallGuide, setShowInstallGuide] = useState(false); const [showAllConnections, setShowAllConnections] = useState(false); const [copiedUrl, copyText] = useCopyText(); - const connectionsBeforeApprovalRef = useRef>(new Set()); const previousConnectionCountRef = useRef(null); - const connectionsQuery = useQuery({ - queryKey: connectionsQueryKey, - refetchOnWindowFocus: "always", - queryFn: async () => { - const result = await api<{ connections: BrowserExtensionConnection[] }>( - "/api/v1/browser-extension/connections" - ); - return result.connections; - }, - }); - const revokeMutation = useMutation({ - mutationFn: (connectionId: string) => - api(`/api/v1/browser-extension/connections/${connectionId}`, { - method: "DELETE", - }), - onSuccess: (_result, connectionId) => { - queryClient.setQueryData( - connectionsQueryKey, - (connections) => - connections?.filter((connection) => connection.id !== connectionId) - ); - }, - }); - - useEffect(() => { - if (approvalState !== "waiting") return; - - let cancelled = false; - let refreshTimer: ReturnType | undefined; - let finishWaiting: (() => void) | undefined; - - const refreshUntilConnectionAppears = async () => { - await queryClient.cancelQueries({ queryKey: connectionsQueryKey }); - - for (let attempt = 0; attempt < postApprovalRefreshAttempts; attempt++) { - if (cancelled) return; - - await queryClient.refetchQueries({ - queryKey: connectionsQueryKey, - type: "active", - }); - if (cancelled) return; - - const connections = - queryClient.getQueryData( - connectionsQueryKey - ) ?? []; - if ( - connections.some( - (connection) => - !connectionsBeforeApprovalRef.current.has(connection.id) - ) - ) { - setApprovalState("connected"); - return; - } - - if (attempt < postApprovalRefreshAttempts - 1) { - await new Promise((resolve) => { - finishWaiting = resolve; - refreshTimer = setTimeout(resolve, postApprovalRefreshDelayMs); - }); - finishWaiting = undefined; - refreshTimer = undefined; - } - } - - if (!cancelled) setApprovalState("timedOut"); - }; - - void refreshUntilConnectionAppears(); - - return () => { - cancelled = true; - if (refreshTimer !== undefined) clearTimeout(refreshTimer); - finishWaiting?.(); - }; - }, [approvalState, queryClient]); - - useEffect(() => { - if (approvalState !== "connected" || (!pairingId && !code)) return; - const nextParams = new URLSearchParams(searchParams); - nextParams.delete("browserExtensionPairing"); - nextParams.delete("code"); - setSearchParams(nextParams, { replace: true }); - }, [approvalState, code, pairingId, searchParams, setSearchParams]); useEffect(() => { if (!connectionsQuery.data) return; @@ -156,16 +33,6 @@ export function BrowserExtensionSettings(): JSX.Element { const connectionCount = connectionsQuery.data.length; const previousConnectionCount = previousConnectionCountRef.current; previousConnectionCountRef.current = connectionCount; - const hasPostApprovalConnection = connectionsQuery.data.some( - (connection) => !connectionsBeforeApprovalRef.current.has(connection.id) - ); - - if ( - (approvalState === "waiting" || approvalState === "timedOut") && - hasPostApprovalConnection - ) { - setApprovalState("connected"); - } if ( previousConnectionCount !== null && @@ -173,68 +40,10 @@ export function BrowserExtensionSettings(): JSX.Element { ) { setShowInstallGuide(false); } - }, [approvalState, connectionsQuery.data]); - - const approvePairing = async () => { - if (!pairingId || !code) return; - - setApprovalState("approving"); - setError(""); - - const baselineResult = connectionsQuery.data - ? { data: connectionsQuery.data } - : await connectionsQuery.refetch(); - if (!baselineResult.data) { - setError( - "Could not load existing browser connections. Check your connection and try again." - ); - setApprovalState("error"); - return; - } - connectionsBeforeApprovalRef.current = new Set( - baselineResult.data.map((connection) => connection.id) - ); - - try { - const response = await fetch( - `/api/v1/browser-extension/pairings/${encodeURIComponent(pairingId)}/approve`, - { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify({ code }), - } - ); - - if (!response.ok) { - let message = "Could not approve this browser extension connection."; - try { - const body = (await response.json()) as { error?: string }; - message = body.error ?? message; - } catch { - // The default message handles non-JSON error responses. - } - setError(message); - setApprovalState("error"); - return; - } - - setApprovalState("waiting"); - } catch { - setError( - "Unable to reach the server. Check your connection and try again." - ); - setApprovalState("error"); - } - }; + }, [connectionsQuery.data]); const connections = connectionsQuery.data ?? []; const hasConnections = connections.length > 0; - const visibleConnections = showAllConnections - ? connections - : connections.slice(0, initiallyVisibleConnections); - const hiddenConnectionCount = connections.length - visibleConnections.length; - const dispatchUrl = window.location.origin; return (
{(hasPairingRequest || approvalState === "connected") && ( - - -
-
-
- - {approvalState === "connected" - ? "Browser connected" - : approvalState === "timedOut" - ? "Connection still pending" - : "Approve this browser"} - - - {approvalState === "connected" - ? "The extension is ready to send feedback to your agents." - : approvalState === "timedOut" - ? "Dispatch has not seen the browser finish connecting yet." - : "Finish the connection request you started in the extension."} - -
-
- -
- {approvalState === "connected" ? ( -
-
- ) : !pairingRequestIsValid ? ( -

- This browser extension pairing link is incomplete. Return to - the extension and start the connection again. -

- ) : approvalState === "waiting" ? ( -
-
- ) : approvalState === "timedOut" ? ( -
-
-

- Browser has not finished connecting -

-

- Keep the extension open while it finishes the exchange, - then check again. If the request is no longer visible in - the extension, start a new connection there. -

-
- -
- ) : ( -
-
-

- Chrome is requesting permission to connect -

-

- Approve only if you started this request. The extension - will be able to view available agents and send page - feedback to the agent you select. -

-
-
-

- Confirm this code matches the extension -

- - {code} - -
- {approvalState === "error" && ( -

- {error} -

- )} - -
- )} -
-
-
+ void approvePairing()} + onCheckAgain={() => setApprovalState("waiting")} + /> )} {!hasPairingRequest && @@ -386,254 +84,27 @@ export function BrowserExtensionSettings(): JSX.Element { approvalState !== "connected" && !connectionsQuery.isPending && !connectionsQuery.isError && ( - - -
-
-
- - {hasConnections - ? "Dispatch Browser Feedback" - : "Try browser feedback"} - - - {hasConnections - ? `${connections.length} ${connections.length === 1 ? "browser is" : "browsers are"} paired and ready to send selected page context.` - : "Select an element on any web app, add a comment, and send both directly to an agent."} - -
-
- - {!hasConnections && ( -
- - -
- )} - {hasConnections && ( - - )} - - {showInstallGuide && ( -
-
-
-

- Finish setup in Chrome -

-

- The extension is a developer preview, so Chrome loads it - from an unzipped folder for now. -

-
- {hasConnections && ( - - )} -
-
-
-
-
-
-
-
-
-
- - {dispatchUrl} - - -
-
- )} -
-
+ )} {(hasConnections || hasPairingRequest || connectionsQuery.isError) && ( - - - Paired browsers - - Each browser has its own access. Revoking one does not disconnect - the others. - - - - {connectionsQuery.isPending ? ( -

- Loading paired browsers... -

- ) : connectionsQuery.isError ? ( -
-

- Could not load paired browsers. -

- -
- ) : connections.length === 0 ? ( -

- Waiting for this browser to finish connecting. -

- ) : ( -
- {visibleConnections.map((connection) => ( -
-
- ))} - {connections.length > initiallyVisibleConnections && ( - - )} -
- )} - {revokeMutation.isError && ( -

- Could not revoke that browser. Try again. -

- )} -
-
+ void connectionsQuery.refetch()} + showAllConnections={showAllConnections} + setShowAllConnections={setShowAllConnections} + revokeIsPending={revokeMutation.isPending} + revokeIsError={revokeMutation.isError} + onRevoke={(connectionId) => revokeMutation.mutate(connectionId)} + /> )}
); diff --git a/apps/web/src/components/app/browser-extension-setup-card.tsx b/apps/web/src/components/app/browser-extension-setup-card.tsx new file mode 100644 index 00000000..0a5ff240 --- /dev/null +++ b/apps/web/src/components/app/browser-extension-setup-card.tsx @@ -0,0 +1,182 @@ +import { + Check, + ChevronDown, + ChevronUp, + Chrome, + Copy, + Download, + FolderOpen, + Plus, + Puzzle, +} from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +type BrowserExtensionSetupCardProps = { + connectionCount: number; + showInstallGuide: boolean; + setShowInstallGuide: Dispatch>; + copiedUrl: boolean; + onCopyUrl: (text: string) => void; +}; + +export function BrowserExtensionSetupCard({ + connectionCount, + showInstallGuide, + setShowInstallGuide, + copiedUrl, + onCopyUrl, +}: BrowserExtensionSetupCardProps): JSX.Element { + const hasConnections = connectionCount > 0; + const dispatchUrl = window.location.origin; + + return ( + + +
+
+
+ + {hasConnections + ? "Dispatch Browser Feedback" + : "Try browser feedback"} + + + {hasConnections + ? `${connectionCount} ${connectionCount === 1 ? "browser is" : "browsers are"} paired and ready to send selected page context.` + : "Select an element on any web app, add a comment, and send both directly to an agent."} + +
+
+ + {!hasConnections && ( +
+ + +
+ )} + {hasConnections && ( + + )} + + {showInstallGuide && ( +
+
+
+

Finish setup in Chrome

+

+ The extension is a developer preview, so Chrome loads it from + an unzipped folder for now. +

+
+ {hasConnections && ( + + )} +
+
+
+
+
+
+
+
+
+
+ + {dispatchUrl} + + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/app/use-extension-pairing.ts b/apps/web/src/components/app/use-extension-pairing.ts new file mode 100644 index 00000000..940ad8d5 --- /dev/null +++ b/apps/web/src/components/app/use-extension-pairing.ts @@ -0,0 +1,215 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseQueryResult, +} from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { api } from "@/lib/api"; + +export type ApprovalState = + | "idle" + | "approving" + | "waiting" + | "timedOut" + | "connected" + | "error"; + +export type BrowserExtensionConnection = { + id: string; + deviceName: string; + createdAt: string; + expiresAt: string; + lastUsedAt: string | null; +}; + +export const connectionsQueryKey = [ + "browser-extension", + "connections", +] as const; +const postApprovalRefreshAttempts = 21; +const postApprovalRefreshDelayMs = 500; + +export function useExtensionConnections() { + const queryClient = useQueryClient(); + const connectionsQuery = useQuery({ + queryKey: connectionsQueryKey, + refetchOnWindowFocus: "always", + queryFn: async () => { + const result = await api<{ connections: BrowserExtensionConnection[] }>( + "/api/v1/browser-extension/connections" + ); + return result.connections; + }, + }); + const revokeMutation = useMutation({ + mutationFn: (connectionId: string) => + api(`/api/v1/browser-extension/connections/${connectionId}`, { + method: "DELETE", + }), + onSuccess: (_result, connectionId) => { + queryClient.setQueryData( + connectionsQueryKey, + (connections) => + connections?.filter((connection) => connection.id !== connectionId) + ); + }, + }); + return { connectionsQuery, revokeMutation }; +} + +export function useExtensionPairing( + connectionsQuery: UseQueryResult +) { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const pairingId = searchParams.get("browserExtensionPairing"); + const code = searchParams.get("code"); + const hasPairingRequest = pairingId !== null || code !== null; + const pairingRequestIsValid = Boolean(pairingId && code); + const [approvalState, setApprovalState] = useState("idle"); + const [error, setError] = useState(""); + const connectionsBeforeApprovalRef = useRef>(new Set()); + + useEffect(() => { + if (approvalState !== "waiting") return; + + let cancelled = false; + let refreshTimer: ReturnType | undefined; + let finishWaiting: (() => void) | undefined; + + const refreshUntilConnectionAppears = async () => { + await queryClient.cancelQueries({ queryKey: connectionsQueryKey }); + + for (let attempt = 0; attempt < postApprovalRefreshAttempts; attempt++) { + if (cancelled) return; + + await queryClient.refetchQueries({ + queryKey: connectionsQueryKey, + type: "active", + }); + if (cancelled) return; + + const connections = + queryClient.getQueryData( + connectionsQueryKey + ) ?? []; + if ( + connections.some( + (connection) => + !connectionsBeforeApprovalRef.current.has(connection.id) + ) + ) { + setApprovalState("connected"); + return; + } + + if (attempt < postApprovalRefreshAttempts - 1) { + await new Promise((resolve) => { + finishWaiting = resolve; + refreshTimer = setTimeout(resolve, postApprovalRefreshDelayMs); + }); + finishWaiting = undefined; + refreshTimer = undefined; + } + } + + if (!cancelled) setApprovalState("timedOut"); + }; + + void refreshUntilConnectionAppears(); + + return () => { + cancelled = true; + if (refreshTimer !== undefined) clearTimeout(refreshTimer); + finishWaiting?.(); + }; + }, [approvalState, queryClient]); + + useEffect(() => { + if (approvalState !== "connected" || (!pairingId && !code)) return; + const nextParams = new URLSearchParams(searchParams); + nextParams.delete("browserExtensionPairing"); + nextParams.delete("code"); + setSearchParams(nextParams, { replace: true }); + }, [approvalState, code, pairingId, searchParams, setSearchParams]); + + useEffect(() => { + if (!connectionsQuery.data) return; + + const hasPostApprovalConnection = connectionsQuery.data.some( + (connection) => !connectionsBeforeApprovalRef.current.has(connection.id) + ); + if ( + (approvalState === "waiting" || approvalState === "timedOut") && + hasPostApprovalConnection + ) { + setApprovalState("connected"); + } + }, [approvalState, connectionsQuery.data]); + + const approvePairing = async () => { + if (!pairingId || !code) return; + + setApprovalState("approving"); + setError(""); + + const baselineResult = connectionsQuery.data + ? { data: connectionsQuery.data } + : await connectionsQuery.refetch(); + if (!baselineResult.data) { + setError( + "Could not load existing browser connections. Check your connection and try again." + ); + setApprovalState("error"); + return; + } + connectionsBeforeApprovalRef.current = new Set( + baselineResult.data.map((connection) => connection.id) + ); + + try { + const response = await fetch( + `/api/v1/browser-extension/pairings/${encodeURIComponent(pairingId)}/approve`, + { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify({ code }), + } + ); + + if (!response.ok) { + let message = "Could not approve this browser extension connection."; + try { + const body = (await response.json()) as { error?: string }; + message = body.error ?? message; + } catch { + // The default message handles non-JSON error responses. + } + setError(message); + setApprovalState("error"); + return; + } + + setApprovalState("waiting"); + } catch { + setError( + "Unable to reach the server. Check your connection and try again." + ); + setApprovalState("error"); + } + }; + + return { + code, + hasPairingRequest, + pairingRequestIsValid, + approvalState, + setApprovalState, + error, + approvePairing, + }; +}