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 (
+
+ )
+}
+
+/** 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 */}