diff --git a/apps/desktop/src/renderer/src/components/device-list.tsx b/apps/desktop/src/renderer/src/components/device-list.tsx index c191115163..5f533b78b2 100644 --- a/apps/desktop/src/renderer/src/components/device-list.tsx +++ b/apps/desktop/src/renderer/src/components/device-list.tsx @@ -97,11 +97,9 @@ export function DeviceList() { if (devices.length === 0) { return ( -
+
-

- No devices connected yet. -

+

No devices connected yet.

) } diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx index 28026d4981..0dd5eef6c9 100644 --- a/apps/desktop/src/renderer/src/components/qr-code.tsx +++ b/apps/desktop/src/renderer/src/components/qr-code.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { QRCodeSVG } from 'qrcode.react' import { Button } from '@multica/ui/components/ui/button' -import { RefreshCw, CheckCircle, Copy } from 'lucide-react' +import { Copy, Check } from 'lucide-react' + +// ============ Types ============ export interface QRCodeData { type: 'multica-connect' @@ -16,51 +18,177 @@ export interface ConnectionQRCodeProps { gateway: string hubId: string agentId: string - /** QR code expiry time in seconds (default: 30) */ expirySeconds?: number - /** Size of the QR code in pixels (default: 180) */ size?: number - /** Callback when token is refreshed */ - onRefresh?: (data: QRCodeData) => void } -/** - * Generate a secure random token for QR code authentication - */ +// ============ Hooks ============ + +/** Generate a secure random token */ function generateToken(): string { return crypto.randomUUID() } /** - * ConnectionQRCode - A QR code component for sharing Agent connection info + * Hook to manage QR token lifecycle + * - Generates token on mount + * - Auto-refreshes when expired + * - Registers token with Hub + */ +function useQRToken(agentId: string, expirySeconds: number) { + const [token, setToken] = useState(generateToken) + const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) + + const refresh = useCallback(() => { + const newToken = generateToken() + const newExpiry = Date.now() + expirySeconds * 1000 + setToken(newToken) + setExpiresAt(newExpiry) + window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry) + }, [agentId, expirySeconds]) + + // Register initial token + useEffect(() => { + window.electronAPI?.hub.registerToken(token, agentId, expiresAt) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return { token, expiresAt, refresh } +} + +/** + * Hook for countdown timer + * Returns remaining seconds, auto-updates every second + */ +function useCountdown(expiresAt: number, onExpire: () => void) { + const [remaining, setRemaining] = useState(() => + Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) + ) + const onExpireRef = useRef(onExpire) + onExpireRef.current = onExpire + + useEffect(() => { + // Reset when expiresAt changes + setRemaining(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))) + + const id = setInterval(() => { + const next = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) + setRemaining(next) + if (next === 0) onExpireRef.current() + }, 1000) + + return () => clearInterval(id) + }, [expiresAt]) + + return remaining +} + +/** + * Hook for clipboard copy with feedback + */ +function useCopyToClipboard(timeout = 2000) { + const [copied, setCopied] = useState(false) + + const copy = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), timeout) + return true + } catch { + return false + } + }, [timeout]) + + return { copied, copy } +} + +// ============ Components ============ + +/** Corner accent decoration */ +function CornerAccent({ position }: { position: 'tl' | 'tr' | 'bl' | 'br' }) { + const positionClasses = { + tl: '-top-2 -left-2 border-t-2 border-l-2 rounded-tl-lg', + tr: '-top-2 -right-2 border-t-2 border-r-2 rounded-tr-lg', + bl: '-bottom-2 -left-2 border-b-2 border-l-2 rounded-bl-lg', + br: '-bottom-2 -right-2 border-b-2 border-r-2 rounded-br-lg', + } + + return ( +
+ ) +} + +/** QR code frame with corner accents */ +function QRCodeFrame({ children }: { children: React.ReactNode }) { + return ( +
+ + + + +
{children}
+
+ ) +} + +/** Format seconds as M:SS */ +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${s.toString().padStart(2, '0')}` +} + +/** Expiry timer display */ +function ExpiryTimer({ remaining }: { remaining: number }) { + // Derive display state from remaining seconds (no extra state needed) + const isWarning = remaining > 0 && remaining < 10 + + return ( + + Expires in {formatTime(remaining)} + + ) +} + +/** Copy link button */ +function CopyLinkButton({ url }: { url: string }) { + const { copied, copy } = useCopyToClipboard() + + return ( + + ) +} + +// ============ Main Component ============ + +/** + * ConnectionQRCode - QR code for mobile app connection * - * Features: - * - Generates time-limited tokens for secure connections - * - Countdown timer showing expiry time - * - Refresh button to generate new token - * - Copy link button for manual sharing - * - Decorative corner accents for visual polish + * Architecture: + * - useQRToken: manages token generation and Hub registration + * - useCountdown: handles timer with auto-refresh on expiry + * - Pure child components for display (no state) */ export function ConnectionQRCode({ gateway, hubId, agentId, expirySeconds = 30, - size = 180, - onRefresh, + size = 200, }: ConnectionQRCodeProps) { - const [token, setToken] = useState(generateToken) - const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) - const [remainingSeconds, setRemainingSeconds] = useState(expirySeconds) - const [copied, setCopied] = useState(false) + const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds) + const remaining = useCountdown(expiresAt, refresh) - // Register initial token with Hub on mount - useEffect(() => { - window.electronAPI?.hub.registerToken(token, agentId, expiresAt) - // eslint-disable-next-line react-hooks/exhaustive-deps -- only on mount - }, []) - - // QR code data payload + // Derive QR data and URL from current token (computed during render) const qrData: QRCodeData = useMemo( () => ({ type: 'multica-connect', @@ -73,7 +201,6 @@ export function ConnectionQRCode({ [gateway, hubId, agentId, token, expiresAt] ) - // URL format for the connection const connectionUrl = useMemo(() => { const params = new URLSearchParams({ gateway, @@ -85,130 +212,22 @@ export function ConnectionQRCode({ return `multica://connect?${params.toString()}` }, [gateway, hubId, agentId, token, expiresAt]) - // Refresh token handler - const handleRefresh = useCallback(() => { - const newToken = generateToken() - const newExpires = Date.now() + expirySeconds * 1000 - - setToken(newToken) - setExpiresAt(newExpires) - setRemainingSeconds(expirySeconds) - - // Register new token with Hub for verification - window.electronAPI?.hub.registerToken(newToken, agentId, newExpires) - - if (onRefresh) { - onRefresh({ - type: 'multica-connect', - gateway, - hubId, - agentId, - token: newToken, - expires: newExpires, - }) - } - }, [gateway, hubId, agentId, expirySeconds, onRefresh]) - - // Countdown timer - useEffect(() => { - const timer = setInterval(() => { - const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) - setRemainingSeconds(remaining) - - // Auto-refresh when expired - if (remaining === 0) { - handleRefresh() - } - }, 1000) - - return () => clearInterval(timer) - }, [expiresAt, handleRefresh]) - - // Copy link handler - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(connectionUrl) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy link:', err) - } - } - - // Format remaining time as M:SS - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - const s = seconds % 60 - return `${m}:${s.toString().padStart(2, '0')}` - } - - // Warning state when less than 1 minute remaining - const isExpiringSoon = remainingSeconds < 60 && remainingSeconds > 0 - const isExpired = remainingSeconds === 0 - return ( -
- {/* QR Code with decorative corners */} -
- {/* Corner accents */} -
-
-
-
+
+ + + - {/* QR Code */} -
- -
- - {/* Expired overlay */} - {isExpired && ( -
- -
- )} -
- - {/* Info section */} -
- {/* Expiry timer */} -
- - {isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`} - - -
+
+ +
) diff --git a/apps/desktop/src/renderer/src/pages/clients.tsx b/apps/desktop/src/renderer/src/pages/clients.tsx index 3afb4ce40f..88fe44947d 100644 --- a/apps/desktop/src/renderer/src/pages/clients.tsx +++ b/apps/desktop/src/renderer/src/pages/clients.tsx @@ -15,7 +15,7 @@ import { TabsList, TabsTrigger, } from '@multica/ui/components/ui/tabs' -import { QrCode, Radio, Smartphone } from 'lucide-react' +import { QrCode, Radio, Smartphone, WifiOff, Loader2 } from 'lucide-react' import { useChannelsStore } from '../stores/channels' import { useHubStore, selectPrimaryAgent } from '../stores/hub' import { ConnectionQRCode } from '../components/qr-code' @@ -183,10 +183,65 @@ function ChannelsTab() { ) } +/** QR Code card with show/hide toggle */ +function QRCodeCard({ + gateway, + hubId, + agentId, +}: { + gateway: string + hubId: string + agentId: string +}) { + const [expanded, setExpanded] = useState(true) + + return ( + + +
+
+ Scan to Connect + Open Multica on your phone and scan. +
+ +
+
+ {expanded && ( + + + + )} +
+ ) +} + +/** Authorized devices card */ +function DevicesCard() { + return ( + + + Authorized Devices + Devices you've approved to access your agent. + + + + + + ) +} + function MulticaAppTab() { const { hubInfo, agents } = useHubStore() const primaryAgent = selectPrimaryAgent(agents) - const [qrCodeExpanded, setQrCodeExpanded] = useState(false) return (
@@ -194,67 +249,49 @@ function MulticaAppTab() { Scan to connect from your phone. Manage authorized devices.

-
- {/* QR Code Section */} - - -
-
- Scan to Connect - - Open Multica on your phone and scan. - -
- -
-
- -
- {qrCodeExpanded ? ( - - ) : ( - - )} -
-
-
- - {/* Device List Section */} - - - Authorized Devices - - Devices you've approved to access your agent. - - - - - - +
+ +
) } +/** Gateway status indicator - only shows when disconnected/error */ +function GatewayStatus() { + const { hubInfo } = useHubStore() + const state = hubInfo?.connectionState ?? 'disconnected' + const url = hubInfo?.url ?? 'Unknown' + + // Only show when not connected + const isConnected = state === 'connected' || state === 'registered' + if (isConnected) return null + + const isConnecting = state === 'connecting' || state === 'reconnecting' + + return ( +
+ {isConnecting ? ( + + ) : ( + + )} + + {state === 'connecting' && 'Connecting to gateway...'} + {state === 'reconnecting' && 'Reconnecting to gateway...'} + {state === 'disconnected' && 'Gateway disconnected'} + + + {url} + +
+ ) +} + export default function ClientsPage() { return (
@@ -265,6 +302,9 @@ export default function ClientsPage() {

Access your agent from anywhere. Connect via third-party platforms or the Multica mobile app.

+
+ +
{/* Tabs */}