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 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan
2026-02-03 18:25:30 +08:00
parent 150fde80a9
commit 03bcd853d3
4 changed files with 801 additions and 0 deletions

View File

@@ -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 (
<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" />
{/* 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}>
<HugeiconsIcon icon={RefreshIcon} className="size-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
{/* Info section */}
<div className="mt-6 text-center space-y-3">
<p className="text-sm text-muted-foreground">
Scan with your phone to connect
</p>
{/* Expiry timer */}
<div className="flex items-center gap-3 justify-center">
<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>
{!isExpired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleRefresh}
>
<HugeiconsIcon icon={RefreshIcon} className="size-3" />
Refresh
</Button>
)}
</div>
{/* Copy link button */}
<Button
variant="outline"
size="sm"
className="text-xs gap-1.5"
onClick={handleCopyLink}
>
<HugeiconsIcon
icon={copied ? CheckmarkCircle02Icon : Copy01Icon}
className="size-3.5"
/>
{copied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</div>
)
}
export default ConnectionQRCode

View File

@@ -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<SkillSource, string> = {
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<SkillSource, string> = {
bundled: 'Built-in Skills',
global: 'Global Skills',
profile: 'Profile Skills',
}
interface SkillListProps {
skills: SkillInfo[]
loading: boolean
error: string | null
onToggleSkill: (skillId: string) => Promise<void>
onRefresh: () => Promise<void>
}
export function SkillList({
skills,
loading,
error,
onToggleSkill,
onRefresh,
}: SkillListProps) {
// Track toggling state for individual skills
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(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<SkillSource, SkillInfo[]> = {
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 (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading skills...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="gap-1.5"
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Skills grouped by source */}
{sourceOrder.map((source) => {
const sourceSkills = skillsBySource[source]
if (sourceSkills.length === 0) return null
return (
<div key={source} className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<span
className={`inline-block w-2 h-2 rounded-full ${
source === 'bundled'
? 'bg-blue-500'
: source === 'global'
? 'bg-green-500'
: 'bg-purple-500'
}`}
/>
{SOURCE_TITLES[source]}
<span className="text-xs">({sourceSkills.length})</span>
</h3>
<div className="space-y-1">
{sourceSkills.map((skill) => {
const isToggling = togglingSkills.has(skill.id)
return (
<div
key={skill.id}
className="flex items-center justify-between px-4 py-3 rounded-lg border hover:bg-muted/30 transition-colors"
>
{/* Left: Name + Description */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{skill.name}</span>
<code className="text-xs text-muted-foreground font-mono">
/{skill.id}
</code>
<Badge variant="secondary" className={`text-xs ${SOURCE_COLORS[skill.source]}`}>
{skill.source}
</Badge>
</div>
<p className="text-sm text-muted-foreground truncate mt-0.5">
{skill.description}
</p>
{skill.triggers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{skill.triggers.slice(0, 3).map((trigger) => (
<code
key={trigger}
className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
{trigger}
</code>
))}
{skill.triggers.length > 3 && (
<span className="text-xs text-muted-foreground">
+{skill.triggers.length - 3} more
</span>
)}
</div>
)}
</div>
{/* Center: Status */}
<div className="flex items-center gap-2 px-4">
<div
className={`flex items-center gap-1 ${
skill.enabled
? 'text-green-600 dark:text-green-400'
: 'text-muted-foreground'
}`}
>
<HugeiconsIcon
icon={skill.enabled ? CheckmarkCircle02Icon : Cancel01Icon}
className="size-4"
/>
<span className="text-xs font-medium">
{skill.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
{/* Right: Toggle */}
<div className="flex items-center gap-2">
{isToggling && (
<HugeiconsIcon
icon={Loading03Icon}
className="size-4 animate-spin text-muted-foreground"
/>
)}
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill.id)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
</div>
)
})}
{/* Empty state */}
{skills.length === 0 && !loading && (
<div className="text-center py-12 text-muted-foreground">
<p>No skills found.</p>
</div>
)}
{/* Note about persistence */}
<p className="text-xs text-muted-foreground text-center">
Changes are saved automatically. Restart Agent session to apply skill changes.
</p>
</div>
)
}
export default SkillList

View File

@@ -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<string, typeof FolderOpenIcon> = {
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<void>
onRefresh: () => Promise<void>
}
export function ToolList({
tools,
groups,
loading,
error,
onToggleTool,
onRefresh,
}: ToolListProps) {
// Track which groups are expanded
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(groups.map((g) => g.id))
)
// Track toggling state for individual tools
const [togglingTools, setTogglingTools] = useState<Set<string>>(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 (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading tools...</span>
</div>
)
}
return (
<div className="space-y-6">
{/* Header: Refresh button */}
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-muted-foreground">
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{/* Error message */}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Tool groups */}
<div className="space-y-2">
{toolsByGroup.map((group) => {
const isExpanded = expandedGroups.has(group.id)
const GroupIcon = GROUP_ICONS[group.id] || CodeIcon
return (
<div
key={group.id}
className="border rounded-lg overflow-hidden"
>
{/* Group header */}
<button
onClick={() => toggleGroup(group.id)}
className="w-full flex items-center justify-between px-4 py-3 bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<HugeiconsIcon icon={GroupIcon} className="size-5 text-muted-foreground" />
<span className="font-medium">{group.name}</span>
<span className="text-xs text-muted-foreground">
{group.enabledCount}/{group.totalCount} enabled
</span>
</div>
<HugeiconsIcon
icon={isExpanded ? ArrowUp01Icon : ArrowDown01Icon}
className="size-4 text-muted-foreground"
/>
</button>
{/* Group tools */}
{isExpanded && (
<div className="divide-y">
{group.tools.map((tool) => {
const isToggling = togglingTools.has(tool.name)
return (
<div
key={tool.name}
className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className="text-sm font-mono font-medium">
{tool.name}
</code>
{!tool.enabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
disabled
</span>
)}
</div>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{tool.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={tool.enabled}
onCheckedChange={() => handleToggleTool(tool.name)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{/* Note about persistence */}
<p className="text-xs text-muted-foreground text-center">
Changes are saved automatically and apply to the running Agent immediately.
</p>
</div>
)
}
export default ToolList

View File

@@ -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 <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 rounded-xl p-6 ring-1 duration-100 fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 shadow-lg outline-none",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogOverlay,
DialogPortal,
}