Files
2026-05-01 21:39:30 +08:00

470 lines
17 KiB
TypeScript

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 the URL fragment so it is not sent in HTTP requests.
function getTokenFromUrl(): string | undefined {
try {
const url = new URL(window.location.href);
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
return hashParams.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);
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
// Only infer if we have a fragment token (indicates user came from server-printed URL)
if (!hashParams.has('token')) {
return undefined;
}
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${url.host}/ws`;
} catch {
return undefined;
}
}
function scrubTokenFromUrl(): void {
try {
const url = new URL(window.location.href);
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
if (!hashParams.has('token')) {
return;
}
hashParams.delete('token');
const nextHash = hashParams.toString();
url.hash = nextHash ? `#${nextHash}` : '';
window.history.replaceState(null, '', url.toString());
} catch {
return;
}
}
// 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<BrowserToolResult>;
/** 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<ACPSettings>(() => getInitialSettings(inferFromUrl));
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
const [error, setError] = useState<string | null>(null);
const [isShaking, setIsShaking] = useState(false);
const [client, setClient] = useState<ACPClient | null>(null);
const [maxHeight, setMaxHeight] = useState<number>(200);
const contentRef = useRef<HTMLDivElement>(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<ACPSettings>(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,
});
useLayoutEffect(() => {
if (inferFromUrl) {
scrubTokenFromUrl();
}
}, [inferFromUrl]);
// 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<HTMLInputElement>(null);
// Handle file selection from album
const handleFileSelect = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
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 = <K extends keyof ACPSettings>(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<ConnectionState, string> = {
disconnected: 'Disconnected',
connecting: 'Connecting...',
connected: 'Connected',
error: 'Error',
};
return (
<div className="bg-background/80 backdrop-blur-sm">
<div className="max-w-md mx-auto border-b">
{/* Status Bar - Always visible */}
<button
onClick={() => onExpandedChange(!expanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<StatusDot state={connectionState} />
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
{isConnected && displayUrl && <span className="text-xs text-muted-foreground"> {displayUrl}</span>}
</div>
<div className="flex items-center gap-1">
<div onClick={e => e.stopPropagation()}>
<ThemeToggle />
</div>
<ChevronDown
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
expanded ? 'rotate-180' : ''
}`}
/>
</div>
</button>
{/* Expandable Settings Panel */}
<div
className="overflow-hidden transition-all duration-200 ease-out"
style={{
maxHeight: expanded ? maxHeight : 0,
opacity: expanded ? 1 : 0,
}}
>
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? 'animate-shake' : ''}`}>
{/* Hidden file input for album scanning */}
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} className="hidden" />
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
{isScanning &&
createPortal(
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<video ref={videoRef} className="flex-1 w-full object-cover" />
<Button
onClick={stopScanning}
variant="ghost"
size="sm"
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
>
<X className="h-5 w-5" />
</Button>
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
<Button onClick={handleSelectFromAlbum} variant="secondary" size="sm" className="h-9 px-4">
<Image className="h-4 w-4 mr-2" />
Select from Album
</Button>
<span className="text-sm text-white/80">or point camera at QR code</span>
</div>
</div>,
document.body,
)}
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
<div className={`space-y-3 ${isScanning ? 'invisible' : ''}`}>
{/* Server URL */}
<div className="space-y-1.5">
<Label htmlFor="proxy-url">Server</Label>
<div className="flex gap-2">
{showScanButton && !isConnected && !isConnecting && (
<Button
onClick={handleStartScanning}
variant="outline"
size="sm"
className="h-9 px-3"
title="Scan QR code"
type="button"
>
<ScanLine className="h-4 w-4" />
</Button>
)}
<InputGroup className="flex-1" data-disabled={isConnected || isConnecting}>
<InputGroupAddon>
<Globe />
</InputGroupAddon>
<InputGroupInput
id="proxy-url"
value={settings.proxyUrl}
onChange={e => updateSetting('proxyUrl', e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
disabled={isConnected || isConnecting}
aria-invalid={!!error}
/>
</InputGroup>
{!isConnected ? (
<Button
onClick={handleConnect}
disabled={isConnecting}
size="sm"
className="h-9 px-4"
type="button"
>
{isConnecting ? '...' : 'Connect'}
</Button>
) : (
<Button
onClick={handleDisconnect}
variant="destructive"
size="sm"
className="h-9 px-4"
type="button"
>
Disconnect
</Button>
)}
</div>
</div>
{/* Auth Token - only shown if enabled */}
{showTokenInput && (
<div className="space-y-1.5">
<Label htmlFor="auth-token">
Auth Token
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
</Label>
<InputGroup data-disabled={isConnected || isConnecting}>
<InputGroupAddon>
<KeyRound />
</InputGroupAddon>
<InputGroupInput
id="auth-token"
value={settings.token || ''}
onChange={e => updateSetting('token', e.target.value || undefined)}
onKeyDown={handleInputKeyDown}
placeholder="For remote access"
disabled={isConnected || isConnecting}
type="password"
aria-invalid={!!error}
className="font-mono"
/>
</InputGroup>
</div>
)}
{/* Working Directory */}
<div className="space-y-1.5">
<Label htmlFor="working-dir">
Working Directory
<span className="text-muted-foreground font-normal ml-1.5">optional</span>
</Label>
<InputGroup data-disabled={isConnected || isConnecting}>
<InputGroupAddon>
<FolderOpen />
</InputGroupAddon>
<InputGroupInput
id="working-dir"
value={settings.cwd || ''}
onChange={e => updateSetting('cwd', e.target.value || undefined)}
onKeyDown={handleInputKeyDown}
placeholder="/path/to/project"
disabled={isConnected || isConnecting}
aria-invalid={!!error}
className="font-mono"
/>
</InputGroup>
</div>
</div>
{/* Error Message */}
{error && <div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">{error}</div>}
</div>
</div>
</div>
</div>
);
}