From 03bcd853d39d5880430262224d065db9a18ca222 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Tue, 3 Feb 2026 18:25:30 +0800 Subject: [PATCH] feat(desktop): add UI components for Tools and Skills management - Create tool-list.tsx with collapsible groups and toggle switches - Create skill-list.tsx with status badges and action dialogs - Create qr-code.tsx for connection QR code display - Add dialog.tsx component to @multica/ui Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/components/qr-code.tsx | 228 +++++++++++++++++++++ apps/desktop/src/components/skill-list.tsx | 225 ++++++++++++++++++++ apps/desktop/src/components/tool-list.tsx | 208 +++++++++++++++++++ packages/ui/src/components/ui/dialog.tsx | 140 +++++++++++++ 4 files changed, 801 insertions(+) create mode 100644 apps/desktop/src/components/qr-code.tsx create mode 100644 apps/desktop/src/components/skill-list.tsx create mode 100644 apps/desktop/src/components/tool-list.tsx create mode 100644 packages/ui/src/components/ui/dialog.tsx diff --git a/apps/desktop/src/components/qr-code.tsx b/apps/desktop/src/components/qr-code.tsx new file mode 100644 index 000000000..35ba0d8d9 --- /dev/null +++ b/apps/desktop/src/components/qr-code.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' +import { QRCodeSVG } from 'qrcode.react' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RefreshIcon, + CheckmarkCircle02Icon, + Copy01Icon, +} from '@hugeicons/core-free-icons' + +export interface QRCodeData { + type: 'multica-connect' + gateway: string + hubId: string + agentId: string + token: string + expires: number +} + +export interface ConnectionQRCodeProps { + gateway: string + hubId: string + agentId: string + /** QR code expiry time in seconds (default: 300 = 5 minutes) */ + 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 + */ +function generateToken(): string { + return crypto.randomUUID() +} + +/** + * ConnectionQRCode - A QR code component for sharing Agent connection info + * + * 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 + */ +export function ConnectionQRCode({ + gateway, + hubId, + agentId, + expirySeconds = 300, + size = 180, + onRefresh, +}: ConnectionQRCodeProps) { + const [token, setToken] = useState(() => generateToken()) + const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000) + const [remainingSeconds, setRemainingSeconds] = useState(expirySeconds) + const [copied, setCopied] = useState(false) + + // QR code data payload + const qrData: QRCodeData = useMemo( + () => ({ + type: 'multica-connect', + gateway, + hubId, + agentId, + token, + expires: expiresAt, + }), + [gateway, hubId, agentId, token, expiresAt] + ) + + // URL format for the connection + const connectionUrl = useMemo(() => { + const params = new URLSearchParams({ + gateway, + hub: hubId, + agent: agentId, + token, + exp: expiresAt.toString(), + }) + return `multica://connect?${params.toString()}` + }, [gateway, hubId, agentId, token, expiresAt]) + + // 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]) + + // Refresh token handler + const handleRefresh = useCallback(() => { + const newToken = generateToken() + const newExpires = Date.now() + expirySeconds * 1000 + + setToken(newToken) + setExpiresAt(newExpires) + setRemainingSeconds(expirySeconds) + + if (onRefresh) { + onRefresh({ + type: 'multica-connect', + gateway, + hubId, + agentId, + token: newToken, + expires: newExpires, + }) + } + }, [gateway, hubId, agentId, expirySeconds, onRefresh]) + + // 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 */} +
+

+ Scan with your phone to connect +

+ + {/* Expiry timer */} +
+ + {isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`} + + {!isExpired && ( + + )} +
+ + {/* Copy link button */} + +
+
+ ) +} + +export default ConnectionQRCode diff --git a/apps/desktop/src/components/skill-list.tsx b/apps/desktop/src/components/skill-list.tsx new file mode 100644 index 000000000..6f18501b0 --- /dev/null +++ b/apps/desktop/src/components/skill-list.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react' +import { Button } from '@multica/ui/components/ui/button' +import { Badge } from '@multica/ui/components/ui/badge' +import { Switch } from '@multica/ui/components/ui/switch' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RotateClockwiseIcon, + Loading03Icon, + CheckmarkCircle02Icon, + Cancel01Icon, +} from '@hugeicons/core-free-icons' +import type { SkillInfo, SkillSource } from '../hooks/use-skills' + +// Source badge colors +const SOURCE_COLORS: Record = { + bundled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + global: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', + profile: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', +} + +// Source section titles +const SOURCE_TITLES: Record = { + bundled: 'Built-in Skills', + global: 'Global Skills', + profile: 'Profile Skills', +} + +interface SkillListProps { + skills: SkillInfo[] + loading: boolean + error: string | null + onToggleSkill: (skillId: string) => Promise + onRefresh: () => Promise +} + +export function SkillList({ + skills, + loading, + error, + onToggleSkill, + onRefresh, +}: SkillListProps) { + // Track toggling state for individual skills + const [togglingSkills, setTogglingSkills] = useState>(new Set()) + + const handleToggleSkill = async (skillId: string) => { + setTogglingSkills((prev) => new Set(prev).add(skillId)) + try { + await onToggleSkill(skillId) + } finally { + setTogglingSkills((prev) => { + const next = new Set(prev) + next.delete(skillId) + return next + }) + } + } + + // Group skills by source + const skillsBySource: Record = { + bundled: skills.filter((s) => s.source === 'bundled'), + global: skills.filter((s) => s.source === 'global'), + profile: skills.filter((s) => s.source === 'profile'), + } + + // Order of sources to display + const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile'] + + if (loading && skills.length === 0) { + return ( +
+ + Loading skills... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ {skills.filter((s) => s.enabled).length} of {skills.length} skills enabled +
+ +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Skills grouped by source */} + {sourceOrder.map((source) => { + const sourceSkills = skillsBySource[source] + if (sourceSkills.length === 0) return null + + return ( +
+

+ + {SOURCE_TITLES[source]} + ({sourceSkills.length}) +

+
+ {sourceSkills.map((skill) => { + const isToggling = togglingSkills.has(skill.id) + + return ( +
+ {/* Left: Name + Description */} +
+
+ {skill.name} + + /{skill.id} + + + {skill.source} + +
+

+ {skill.description} +

+ {skill.triggers.length > 0 && ( +
+ {skill.triggers.slice(0, 3).map((trigger) => ( + + {trigger} + + ))} + {skill.triggers.length > 3 && ( + + +{skill.triggers.length - 3} more + + )} +
+ )} +
+ + {/* Center: Status */} +
+
+ + + {skill.enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {/* Right: Toggle */} +
+ {isToggling && ( + + )} + handleToggleSkill(skill.id)} + disabled={isToggling} + /> +
+
+ ) + })} +
+
+ ) + })} + + {/* Empty state */} + {skills.length === 0 && !loading && ( +
+

No skills found.

+
+ )} + + {/* Note about persistence */} +

+ Changes are saved automatically. Restart Agent session to apply skill changes. +

+
+ ) +} + +export default SkillList diff --git a/apps/desktop/src/components/tool-list.tsx b/apps/desktop/src/components/tool-list.tsx new file mode 100644 index 000000000..2ecc05d52 --- /dev/null +++ b/apps/desktop/src/components/tool-list.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react' +import { Switch } from '@multica/ui/components/ui/switch' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { + RotateClockwiseIcon, + FolderOpenIcon, + CodeIcon, + GlobalIcon, + AiBrainIcon, + ArrowDown01Icon, + ArrowUp01Icon, + Loading03Icon, +} from '@hugeicons/core-free-icons' +import type { ToolInfo, ToolGroup } from '../hooks/use-tools' + +// Group icons +const GROUP_ICONS: Record = { + fs: FolderOpenIcon, + runtime: CodeIcon, + web: GlobalIcon, + memory: AiBrainIcon, + other: CodeIcon, +} + +interface ToolListProps { + tools: ToolInfo[] + groups: ToolGroup[] + loading: boolean + error: string | null + onToggleTool: (toolName: string) => Promise + onRefresh: () => Promise +} + +export function ToolList({ + tools, + groups, + loading, + error, + onToggleTool, + onRefresh, +}: ToolListProps) { + // Track which groups are expanded + const [expandedGroups, setExpandedGroups] = useState>( + () => new Set(groups.map((g) => g.id)) + ) + + // Track toggling state for individual tools + const [togglingTools, setTogglingTools] = useState>(new Set()) + + const toggleGroup = (groupId: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev) + if (next.has(groupId)) { + next.delete(groupId) + } else { + next.add(groupId) + } + return next + }) + } + + const handleToggleTool = async (toolName: string) => { + setTogglingTools((prev) => new Set(prev).add(toolName)) + try { + await onToggleTool(toolName) + } finally { + setTogglingTools((prev) => { + const next = new Set(prev) + next.delete(toolName) + return next + }) + } + } + + // Group tools by their group + const toolsByGroup = groups.map((group) => ({ + ...group, + tools: tools.filter((t) => t.group === group.id), + enabledCount: tools.filter((t) => t.group === group.id && t.enabled).length, + totalCount: tools.filter((t) => t.group === group.id).length, + })) + + if (loading && tools.length === 0) { + return ( +
+ + Loading tools... +
+ ) + } + + return ( +
+ {/* Header: Refresh button */} +
+
+ {tools.filter((t) => t.enabled).length} of {tools.length} tools enabled +
+ + +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Tool groups */} +
+ {toolsByGroup.map((group) => { + const isExpanded = expandedGroups.has(group.id) + const GroupIcon = GROUP_ICONS[group.id] || CodeIcon + + return ( +
+ {/* Group header */} + + + {/* Group tools */} + {isExpanded && ( +
+ {group.tools.map((tool) => { + const isToggling = togglingTools.has(tool.name) + + return ( +
+
+
+ + {tool.name} + + {!tool.enabled && ( + + disabled + + )} +
+ {tool.description && ( +

+ {tool.description} +

+ )} +
+
+ {isToggling && ( + + )} + handleToggleTool(tool.name)} + disabled={isToggling} + /> +
+
+ ) + })} +
+ )} +
+ ) + })} +
+ + {/* Note about persistence */} +

+ Changes are saved automatically and apply to the running Agent immediately. +

+
+ ) +} + +export default ToolList diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx new file mode 100644 index 000000000..1e14624a2 --- /dev/null +++ b/packages/ui/src/components/ui/dialog.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { Cancel01Icon } from "@hugeicons/core-free-icons" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogOverlay, + DialogPortal, +}