refactor(desktop): improve clients page and QR code component architecture

- Refactor QR code component with extracted hooks (useQRToken, useCountdown, useCopyToClipboard)
- Extract reusable sub-components (QRCodeFrame, ExpiryTimer, CopyLinkButton)
- Extract QRCodeCard and DevicesCard components in clients page
- Add GatewayStatus indicator for connection state
- Minor styling fix for empty device list state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-02-12 16:27:15 +08:00
parent 304c6a9bc6
commit 8efc4ae5aa
3 changed files with 271 additions and 214 deletions

View File

@@ -97,11 +97,9 @@ export function DeviceList() {
if (devices.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center text-center py-8">
<Smartphone className="size-8 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No devices connected yet.
</p>
<p className="text-sm text-muted-foreground">No devices connected yet.</p>
</div>
)
}

View File

@@ -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 (
<div
className={`absolute w-5 h-5 border-muted-foreground/30 ${positionClasses[position]}`}
/>
)
}
/** QR code frame with corner accents */
function QRCodeFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative inline-block">
<CornerAccent position="tl" />
<CornerAccent position="tr" />
<CornerAccent position="bl" />
<CornerAccent position="br" />
<div className="bg-white p-3 rounded-lg">{children}</div>
</div>
)
}
/** 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 (
<span
className={`text-xs font-mono ${
isWarning ? 'text-orange-500' : 'text-muted-foreground'
}`}
>
Expires in {formatTime(remaining)}
</span>
)
}
/** Copy link button */
function CopyLinkButton({ url }: { url: string }) {
const { copied, copy } = useCopyToClipboard()
return (
<Button variant="ghost" size="sm" className="h-7 gap-1.5" onClick={() => copy(url)}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? 'Copied!' : 'Copy Link'}
</Button>
)
}
// ============ 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 (
<div className="flex flex-col items-center">
{/* QR Code with decorative corners */}
<div className="relative">
{/* Corner accents */}
<div className="absolute -top-3 -left-3 w-6 h-6 border-t-2 border-l-2 border-primary/60 rounded-tl-lg" />
<div className="absolute -top-3 -right-3 w-6 h-6 border-t-2 border-r-2 border-primary/60 rounded-tr-lg" />
<div className="absolute -bottom-3 -left-3 w-6 h-6 border-b-2 border-l-2 border-primary/60 rounded-bl-lg" />
<div className="absolute -bottom-3 -right-3 w-6 h-6 border-b-2 border-r-2 border-primary/60 rounded-br-lg" />
<div className="flex flex-col items-center gap-4">
<QRCodeFrame>
<QRCodeSVG
value={JSON.stringify(qrData)}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</QRCodeFrame>
{/* QR Code */}
<div className="bg-white p-4 rounded-xl shadow-lg">
<QRCodeSVG
value={JSON.stringify(qrData)}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</div>
{/* Expired overlay */}
{isExpired && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm rounded-xl flex items-center justify-center">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="size-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
{/* Info section */}
<div className="mt-4 space-y-2">
{/* Expiry timer */}
<div className="flex items-center gap-2">
<span
className={`text-xs font-mono ${
isExpiringSoon
? 'text-orange-500 dark:text-orange-400'
: isExpired
? 'text-red-500 dark:text-red-400'
: 'text-muted-foreground'
}`}
>
{isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`}
</span>
<Button
variant="ghost"
size="sm"
onClick={isExpired ? handleRefresh : handleCopyLink}
>
{isExpired ? (
<RefreshCw className="size-3.5 mr-1" />
) : copied ? (
<CheckCircle className="size-3.5 mr-1" />
) : (
<Copy className="size-3.5 mr-1" />
)}
{isExpired ? 'Refresh' : (copied ? 'Copied!' : 'Copy Link')}
</Button>
</div>
<div className="flex items-center gap-3">
<ExpiryTimer remaining={remaining} />
<CopyLinkButton url={connectionUrl} />
</div>
</div>
)

View File

@@ -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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Scan to Connect</CardTitle>
<CardDescription>Open Multica on your phone and scan.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => setExpanded(!expanded)}>
<QrCode className="size-4 mr-1.5" />
{expanded ? 'Hide' : 'Show'}
</Button>
</div>
</CardHeader>
{expanded && (
<CardContent className="flex justify-center">
<ConnectionQRCode
gateway={gateway}
hubId={hubId}
agentId={agentId}
expirySeconds={30}
size={200}
/>
</CardContent>
)}
</Card>
)
}
/** Authorized devices card */
function DevicesCard() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Authorized Devices</CardTitle>
<CardDescription>Devices you've approved to access your agent.</CardDescription>
</CardHeader>
<CardContent>
<DeviceList />
</CardContent>
</Card>
)
}
function MulticaAppTab() {
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
const [qrCodeExpanded, setQrCodeExpanded] = useState(false)
return (
<div className="space-y-6">
@@ -194,67 +249,49 @@ function MulticaAppTab() {
Scan to connect from your phone. Manage authorized devices.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* QR Code Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Scan to Connect</CardTitle>
<CardDescription>
Open Multica on your phone and scan.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setQrCodeExpanded(!qrCodeExpanded)}
>
<QrCode className="size-4 mr-1.5" />
{qrCodeExpanded ? 'Hide' : 'Show'}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-4">
{qrCodeExpanded ? (
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
expirySeconds={30}
size={160}
/>
) : (
<button
onClick={() => setQrCodeExpanded(true)}
className="flex flex-col items-center justify-center gap-3 p-8 rounded-xl border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 transition-colors cursor-pointer"
>
<QrCode className="size-12 text-muted-foreground/40" />
<span className="text-sm text-muted-foreground">Click to show QR code</span>
</button>
)}
</div>
</CardContent>
</Card>
{/* Device List Section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Authorized Devices</CardTitle>
<CardDescription>
Devices you've approved to access your agent.
</CardDescription>
</CardHeader>
<CardContent>
<DeviceList />
</CardContent>
</Card>
<div className="space-y-6">
<QRCodeCard
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
/>
<DevicesCard />
</div>
</div>
)
}
/** 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 (
<div className="flex items-center gap-2 text-sm rounded-md bg-destructive/10 text-destructive px-3 py-2">
{isConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<WifiOff className="size-4" />
)}
<span>
{state === 'connecting' && 'Connecting to gateway...'}
{state === 'reconnecting' && 'Reconnecting to gateway...'}
{state === 'disconnected' && 'Gateway disconnected'}
</span>
<span className="text-destructive/60 font-mono text-xs truncate max-w-[200px]" title={url}>
{url}
</span>
</div>
)
}
export default function ClientsPage() {
return (
<div className="h-full overflow-auto">
@@ -265,6 +302,9 @@ export default function ClientsPage() {
<p className="text-sm text-muted-foreground">
Access your agent from anywhere. Connect via third-party platforms or the Multica mobile app.
</p>
<div className="mt-2">
<GatewayStatus />
</div>
</div>
{/* Tabs */}