mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user