mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
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:
228
apps/desktop/src/components/qr-code.tsx
Normal file
228
apps/desktop/src/components/qr-code.tsx
Normal 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
|
||||
225
apps/desktop/src/components/skill-list.tsx
Normal file
225
apps/desktop/src/components/skill-list.tsx
Normal 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
|
||||
208
apps/desktop/src/components/tool-list.tsx
Normal file
208
apps/desktop/src/components/tool-list.tsx
Normal 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
|
||||
140
packages/ui/src/components/ui/dialog.tsx
Normal file
140
packages/ui/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user