import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { Button } from "./ui/button"; import { StatusDot } from "./ui/connection-status"; import { ThemeToggle } from "./ui/theme-toggle"; import { Label } from "./ui/label"; import { InputGroup, InputGroupAddon, InputGroupInput, } from "./ui/input-group"; import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from "../src/acp"; import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from "../src/acp"; import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react"; import { useQRScanner, type QRCodeData } from "../src/hooks"; // Get token from URL query param (for pre-filled URLs from server) function getTokenFromUrl(): string | undefined { try { const url = new URL(window.location.href); return url.searchParams.get("token") || undefined; } catch { return undefined; } } // Infer WebSocket URL from current page URL (for pre-filled links from server) // e.g., http://localhost:9315/app?token=xxx -> ws://localhost:9315/ws function inferProxyUrlFromPage(): string | undefined { try { const url = new URL(window.location.href); // Only infer if we have a token param (indicates user came from server-printed URL) if (!url.searchParams.has("token")) { return undefined; } const protocol = url.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${url.host}/ws`; } catch { return undefined; } } // Get initial settings from defaults, with optional URL overrides function getInitialSettings(inferFromUrl: boolean): ACPSettings { const settings = { ...DEFAULT_SETTINGS }; // Override from URL if enabled (for pre-filled links from server) if (inferFromUrl) { const urlToken = getTokenFromUrl(); const inferredUrl = inferProxyUrlFromPage(); if (urlToken) { settings.token = urlToken; } if (inferredUrl) { settings.proxyUrl = inferredUrl; } } return settings; } export interface ACPConnectProps { onClientReady?: (client: ACPClient | null) => void; expanded: boolean; onExpandedChange: (expanded: boolean) => void; /** Handler for browser tool calls (only Chrome extension can execute these) */ browserToolHandler?: (params: BrowserToolParams) => Promise; /** Show token input field (for remote access) */ showTokenInput?: boolean; /** Infer proxy URL and token from page URL (for PWA) */ inferFromUrl?: boolean; /** Placeholder for proxy URL input */ placeholder?: string; /** Show QR code scan button (for mobile) */ showScanButton?: boolean; } export function ACPConnect({ onClientReady, expanded, onExpandedChange, browserToolHandler, showTokenInput = false, inferFromUrl = false, placeholder = "Proxy server URL", showScanButton = false, }: ACPConnectProps) { const [settings, setSettings] = useState(() => getInitialSettings(inferFromUrl)); const [connectionState, setConnectionState] = useState("disconnected"); const [error, setError] = useState(null); const [isShaking, setIsShaking] = useState(false); const [client, setClient] = useState(null); const [maxHeight, setMaxHeight] = useState(200); const contentRef = useRef(null); const hasAutoCollapsedRef = useRef(false); const pendingAutoConnectRef = useRef(false); // Store initial settings in a ref to avoid eslint warning about empty deps const initialSettingsRef = useRef(settings); // QR Scanner hook const handleQRScan = useCallback((data: QRCodeData) => { // Mark for auto-connect (will be triggered by settings useEffect) pendingAutoConnectRef.current = true; // Update settings - this will trigger auto-connect via useEffect setSettings((prev) => ({ ...prev, proxyUrl: data.url, token: data.token, })); }, []); const handleQRError = useCallback((errorMsg: string) => { setError(errorMsg); }, []); const { isScanning, videoRef, startScanning, stopScanning, scanFromFile } = useQRScanner({ onScan: handleQRScan, onError: handleQRError, }); // Recalculate maxHeight after DOM updates (when expanded or isScanning changes) useLayoutEffect(() => { if (expanded && contentRef.current) { setMaxHeight(contentRef.current.scrollHeight); } }, [expanded, isScanning]); // File input ref for album scanning const fileInputRef = useRef(null); // Handle file selection from album const handleFileSelect = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { await scanFromFile(file); stopScanning(); // Close the scanner overlay after album scan } // Reset input to allow re-selecting the same file e.target.value = ""; }, [scanFromFile, stopScanning] ); // Open file picker const handleSelectFromAlbum = useCallback(() => { fileInputRef.current?.click(); }, []); // Initialize client once on mount using initial settings from ref useEffect(() => { const acpClient = new ACPClient(initialSettingsRef.current); acpClient.setConnectionStateHandler((state, err) => { setConnectionState(state); setError(err || null); }); setClient(acpClient); return () => { acpClient.disconnect(); }; }, []); // Register browser tool handler when it changes useEffect(() => { if (client && browserToolHandler) { client.setBrowserToolCallHandler(browserToolHandler); } }, [client, browserToolHandler]); // Update client settings when settings change, and auto-connect if pending useEffect(() => { if (client) { client.updateSettings(settings); // Auto-connect after QR scan (when pendingAutoConnectRef is set) if (pendingAutoConnectRef.current) { pendingAutoConnectRef.current = false; client.connect().catch((e) => { // Ignore disconnect requested - user cancelled intentionally if (e instanceof DisconnectRequestedError) { return; } setError((e as Error).message); setIsShaking(true); setTimeout(() => setIsShaking(false), 500); onExpandedChange(true); }); } } }, [settings, client, onExpandedChange]); // Notify parent when client is ready and auto-collapse on connect useEffect(() => { const isConnected = connectionState === "connected"; onClientReady?.(isConnected ? client : null); // Auto-collapse when connected for the first time if (isConnected && !hasAutoCollapsedRef.current) { hasAutoCollapsedRef.current = true; onExpandedChange(false); } // Reset auto-collapse flag when disconnected if (connectionState === "disconnected") { hasAutoCollapsedRef.current = false; } }, [connectionState, client, onClientReady, onExpandedChange]); const handleConnect = useCallback(async () => { // Prevent duplicate connect calls if already connecting or connected if (!client || connectionState === "connecting" || connectionState === "connected") { return; } setError(null); setIsShaking(false); try { await client.connect(); } catch (e) { // Ignore disconnect requested - user cancelled intentionally if (e instanceof DisconnectRequestedError) { return; } const errorMessage = (e as Error).message; setError(errorMessage); // Trigger shake animation setIsShaking(true); setTimeout(() => setIsShaking(false), 500); // Ensure panel is expanded to show error onExpandedChange(true); } }, [client, connectionState, onExpandedChange]); const handleDisconnect = useCallback(() => { client?.disconnect(); }, [client]); const updateSetting = (key: K, value: ACPSettings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })); }; // Clear error when starting to scan const handleStartScanning = useCallback(() => { setError(null); startScanning(); }, [startScanning]); const isConnected = connectionState === "connected"; const isConnecting = connectionState === "connecting"; const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !isConnected && !isConnecting) { e.preventDefault(); handleConnect(); } }, [isConnected, isConnecting, handleConnect]); // Format URL for display const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, "").replace(/\/ws$/, ""); // Get status label const statusLabels: Record = { disconnected: "Disconnected", connecting: "Connecting...", connected: "Connected", error: "Error", }; return (
{/* Status Bar - Always visible */} {/* Expandable Settings Panel */}
{/* Hidden file input for album scanning */} {/* QR Scanner View - Portal to body to escape backdrop-blur containing block */} {isScanning && createPortal(
, document.body )} {/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
{/* Server URL */}
{showScanButton && !isConnected && !isConnecting && ( )} updateSetting("proxyUrl", e.target.value)} onKeyDown={handleInputKeyDown} placeholder={placeholder} disabled={isConnected || isConnecting} aria-invalid={!!error} /> {!isConnected ? ( ) : ( )}
{/* Auth Token - only shown if enabled */} {showTokenInput && (
updateSetting("token", e.target.value || undefined)} onKeyDown={handleInputKeyDown} placeholder="For remote access" disabled={isConnected || isConnecting} type="password" aria-invalid={!!error} className="font-mono" />
)} {/* Working Directory */}
updateSetting("cwd", e.target.value || undefined)} onKeyDown={handleInputKeyDown} placeholder="/path/to/project" disabled={isConnected || isConnecting} aria-invalid={!!error} className="font-mono" />
{/* Error Message */} {error && (
{error}
)}
); }