+
-
- No devices connected yet.
-
+
No devices connected yet.
)
}
diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx
index 28026d49..0dd5eef6 100644
--- a/apps/desktop/src/renderer/src/components/qr-code.tsx
+++ b/apps/desktop/src/renderer/src/components/qr-code.tsx
@@ -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 (
+
+ )
+}
+
+/** QR code frame with corner accents */
+function QRCodeFrame({ children }: { children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+/** 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 (
+
+ Expires in {formatTime(remaining)}
+
+ )
+}
+
+/** Copy link button */
+function CopyLinkButton({ url }: { url: string }) {
+ const { copied, copy } = useCopyToClipboard()
+
+ return (
+
copy(url)}>
+ {copied ? : }
+ {copied ? 'Copied!' : 'Copy Link'}
+
+ )
+}
+
+// ============ 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 (
-
- {/* QR Code with decorative corners */}
-
- {/* Corner accents */}
-
-
-
-
+
+
+
+
- {/* QR Code */}
-
-
-
-
- {/* Expired overlay */}
- {isExpired && (
-
-
-
- Refresh
-
-
- )}
-
-
- {/* Info section */}
-
- {/* Expiry timer */}
-
-
- {isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`}
-
-
- {isExpired ? (
-
- ) : copied ? (
-
- ) : (
-
- )}
- {isExpired ? 'Refresh' : (copied ? 'Copied!' : 'Copy Link')}
-
-
+
+
+
)
diff --git a/apps/desktop/src/renderer/src/components/skill-list.tsx b/apps/desktop/src/renderer/src/components/skill-list.tsx
index 64210948..58f573b5 100644
--- a/apps/desktop/src/renderer/src/components/skill-list.tsx
+++ b/apps/desktop/src/renderer/src/components/skill-list.tsx
@@ -1,22 +1,14 @@
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 {
- RotateCw,
- Loader2,
- CheckCircle,
- X,
-} from 'lucide-react'
+ Collapsible,
+ CollapsibleTrigger,
+ CollapsibleContent,
+} from '@multica/ui/components/ui/collapsible'
+import { RotateCw, Loader2, ChevronRight } from 'lucide-react'
+import { cn } from '@multica/ui/lib/utils'
import type { SkillInfo, SkillSource } from '../stores/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',
@@ -36,23 +28,21 @@ export function SkillList({
skills,
loading,
error,
- onToggleSkill,
onRefresh,
}: SkillListProps) {
- // Track toggling state for individual skills
- const [togglingSkills, setTogglingSkills] = useState>(new Set())
+ // Track which skills are expanded
+ const [expandedSkills, setExpandedSkills] = 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)
+ const toggleSkill = (skillId: string) => {
+ setExpandedSkills((prev) => {
+ const next = new Set(prev)
+ if (next.has(skillId)) {
next.delete(skillId)
- return next
- })
- }
+ } else {
+ next.add(skillId)
+ }
+ return next
+ })
}
// Group skills by source
@@ -68,8 +58,7 @@ export function SkillList({
if (loading && skills.length === 0) {
return (
-
- Loading skills...
+
)
}
@@ -78,20 +67,20 @@ export function SkillList({
{/* Header */}
-
- {skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
-
+
+ {skills.length} skill{skills.length !== 1 && 's'} available
+
{loading ? (
-
+
) : (
-
+
)}
Refresh
@@ -110,94 +99,53 @@ export function SkillList({
if (sourceSkills.length === 0) return null
return (
-
-
-
- {SOURCE_TITLES[source]}
- ({sourceSkills.length})
-
-
- {sourceSkills.map((skill) => {
- const isToggling = togglingSkills.has(skill.id)
+
+ {/* Section header */}
+
{SOURCE_TITLES[source]}
+
+ {/* Skills card */}
+
+ {sourceSkills.map((skill, index) => {
+ const isExpanded = expandedSkills.has(skill.id)
+ const isLast = index === sourceSkills.length - 1
return (
-
toggleSkill(skill.id)}
>
- {/* 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 ? (
-
- ) : (
-
+ >
+ {skill.name}
+
+ {skill.triggers[0] || `/${skill.id}`}
+
+
+ {skill.version}
+
+
- {skill.enabled ? 'Enabled' : 'Disabled'}
-
-
-
-
- {/* Right: Toggle */}
-
- {isToggling && (
-
- )}
- handleToggleSkill(skill.id)}
- disabled={isToggling}
/>
-
-
+
+
+
+ {skill.description || 'No description'}
+
+
+
)
})}
@@ -207,15 +155,10 @@ export function SkillList({
{/* Empty state */}
{skills.length === 0 && !loading && (
-
-
No skills found.
+
+ No skills found.
)}
-
- {/* Note about persistence */}
-
- Changes are saved automatically. Restart Agent session to apply skill changes.
-
)
}
diff --git a/apps/desktop/src/renderer/src/components/tool-list.tsx b/apps/desktop/src/renderer/src/components/tool-list.tsx
index 9b2501fd..1417cfd8 100644
--- a/apps/desktop/src/renderer/src/components/tool-list.tsx
+++ b/apps/desktop/src/renderer/src/components/tool-list.tsx
@@ -1,18 +1,23 @@
import { useState, useMemo } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
+import {
+ Collapsible,
+ CollapsibleTrigger,
+ CollapsibleContent,
+} from '@multica/ui/components/ui/collapsible'
import {
RotateCw,
FolderOpen,
Code,
Globe,
Brain,
- ChevronDown,
- ChevronUp,
+ ChevronRight,
Loader2,
Clock,
Users,
} from 'lucide-react'
+import { cn } from '@multica/ui/lib/utils'
import type { ToolInfo } from '../stores/tools'
// Group display names
@@ -26,6 +31,17 @@ const GROUP_NAMES: Record
= {
other: 'Other',
}
+// Group descriptions
+const GROUP_DESCRIPTIONS: Record = {
+ fs: 'Read, write, and manage files',
+ runtime: 'Execute code and commands',
+ web: 'Fetch and interact with web content',
+ memory: 'Store and recall information',
+ subagent: 'Delegate tasks to sub-agents',
+ cron: 'Schedule recurring tasks',
+ other: 'Miscellaneous tools',
+}
+
// Group icons
const GROUP_ICONS: Record = {
fs: FolderOpen,
@@ -54,13 +70,14 @@ export function ToolList({
}: ToolListProps) {
// Compute groups from tools
const groups = useMemo(() => {
- const groupIds = [...new Set(tools.map(t => t.group))]
- return groupIds.map(id => ({
+ const groupIds = [...new Set(tools.map((t) => t.group))]
+ return groupIds.map((id) => ({
id,
name: GROUP_NAMES[id] || id,
- tools: tools.filter(t => t.group === id),
- enabledCount: tools.filter(t => t.group === id && t.enabled).length,
- totalCount: tools.filter(t => t.group === id).length,
+ description: GROUP_DESCRIPTIONS[id] || '',
+ tools: tools.filter((t) => t.group === id),
+ enabledCount: tools.filter((t) => t.group === id && t.enabled).length,
+ totalCount: tools.filter((t) => t.group === id).length,
}))
}, [tools])
@@ -100,31 +117,29 @@ export function ToolList({
if (loading && tools.length === 0) {
return (
-
- Loading tools...
+
)
}
return (
- {/* Header: Refresh button */}
-
-
+ {/* Header */}
+
+
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
-
-
+
{loading ? (
-
+
) : (
-
+
)}
Refresh
@@ -138,86 +153,83 @@ export function ToolList({
)}
{/* Tool groups */}
-
- {groups.map((group) => {
- const isExpanded = expandedGroups.has(group.id)
- const GroupIcon = GROUP_ICONS[group.id] || Code
+ {groups.map((group) => {
+ const isExpanded = expandedGroups.has(group.id)
+ const GroupIcon = GROUP_ICONS[group.id] || Code
- return (
-
- {/* Group header */}
-
toggleGroup(group.id)}
- className="w-full flex items-center justify-between px-4 py-3 bg-muted/30 hover:bg-muted/50 transition-colors"
- >
-
-
-
{group.name}
-
- {group.enabledCount}/{group.totalCount} enabled
+ return (
+
+ {/* Section header */}
+
+
+
+
{group.name}
+
+ {group.enabledCount}/{group.totalCount} enabled
+
+
+
+
+ {/* Tools card */}
+
toggleGroup(group.id)}>
+
+
+
+ {group.description}
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
+
+
+
+
+ {group.tools.map((tool, index) => {
+ const isToggling = togglingTools.has(tool.name)
+ const isLast = index === group.tools.length - 1
- {/* Group tools */}
- {isExpanded && (
-
- {group.tools.map((tool) => {
- const isToggling = togglingTools.has(tool.name)
-
- return (
-
-
-
-
- {tool.name}
-
- {!tool.enabled && (
-
- disabled
-
+ return (
+
+
+
{tool.name}
+ {tool.description && (
+
+ {tool.description}
+
)}
- {tool.description && (
-
- {tool.description}
-
- )}
+
+ {isToggling && (
+
+ )}
+ handleToggleTool(tool.name)}
+ disabled={isToggling}
+ />
+
-
- {isToggling && (
-
- )}
- handleToggleTool(tool.name)}
- disabled={isToggling}
- />
-
-
- )
- })}
-
- )}
-
- )
- })}
-
+ )
+ })}
+
+
+
+
+
+ )
+ })}
- {/* Note about persistence */}
-
- Changes are saved automatically and apply to the running Agent immediately.
+ {/* Note */}
+
+ Changes apply immediately to the running Agent.
)
diff --git a/apps/desktop/src/renderer/src/pages/agent/profile.tsx b/apps/desktop/src/renderer/src/pages/agent/profile.tsx
new file mode 100644
index 00000000..d0569e9f
--- /dev/null
+++ b/apps/desktop/src/renderer/src/pages/agent/profile.tsx
@@ -0,0 +1,354 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { Button } from '@multica/ui/components/ui/button'
+import { Input } from '@multica/ui/components/ui/input'
+import { Textarea } from '@multica/ui/components/ui/textarea'
+import {
+ Combobox,
+ ComboboxInput,
+ ComboboxContent,
+ ComboboxList,
+ ComboboxItem,
+ ComboboxEmpty,
+} from '@multica/ui/components/ui/combobox'
+import {
+ Loader2,
+ Check,
+ AlertCircle,
+ ChevronDown,
+} from 'lucide-react'
+import { ApiKeyDialog } from '../../components/api-key-dialog'
+import { OAuthDialog } from '../../components/oauth-dialog'
+import { useProviderStore } from '../../stores/provider'
+import { toast } from '@multica/ui/components/ui/sonner'
+import { cn } from '@multica/ui/lib/utils'
+
+export default function ProfilePage() {
+ const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
+
+ const [profileLoading, setProfileLoading] = useState(true)
+ const [name, setName] = useState('')
+ const [userContent, setUserContent] = useState('')
+ const [hasChanges, setHasChanges] = useState(false)
+ const [originalName, setOriginalName] = useState('')
+ const [originalUserContent, setOriginalUserContent] = useState('')
+
+ const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
+ const [switching, setSwitching] = useState(false)
+ const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
+ const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
+ const [selectedProvider, setSelectedProvider] = useState<{
+ id: string
+ name: string
+ authMethod: 'api-key' | 'oauth'
+ loginCommand?: string
+ } | null>(null)
+ const dropdownRef = useRef
(null)
+
+
+ useEffect(() => {
+ loadProfile()
+ }, [])
+
+ useEffect(() => {
+ setHasChanges(name !== originalName || userContent !== originalUserContent)
+ }, [name, userContent, originalName, originalUserContent])
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setProviderDropdownOpen(false)
+ }
+ }
+ if (providerDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside)
+ }
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [providerDropdownOpen])
+
+ const loadProfile = async () => {
+ setProfileLoading(true)
+ try {
+ const data = await window.electronAPI.profile.get()
+ const loadedName = data.name ?? ''
+ const loadedUserContent = data.userContent ?? ''
+ setName(loadedName)
+ setUserContent(loadedUserContent)
+ setOriginalName(loadedName)
+ setOriginalUserContent(loadedUserContent)
+ } catch (err) {
+ console.error('Failed to load profile:', err)
+ toast.error('Failed to load profile')
+ } finally {
+ setProfileLoading(false)
+ }
+ }
+
+ const handleSaveProfile = useCallback(async () => {
+ try {
+ await window.electronAPI.profile.updateName(name)
+ await window.electronAPI.profile.updateUser(userContent)
+ setOriginalName(name)
+ setOriginalUserContent(userContent)
+ setHasChanges(false)
+ toast.success('Profile saved')
+ } catch (err) {
+ console.error('Failed to save profile:', err)
+ toast.error('Failed to save profile')
+ }
+ }, [name, userContent])
+
+ // Keep ref to latest save function
+ const saveRef = useRef(handleSaveProfile)
+ saveRef.current = handleSaveProfile
+
+ // Show/hide persistent toast for unsaved changes
+ useEffect(() => {
+ const toastId = 'unsaved-changes'
+ if (hasChanges) {
+ toast('Unsaved changes', {
+ id: toastId,
+ duration: Infinity,
+ action: {
+ label: 'Save',
+ onClick: () => saveRef.current()
+ }
+ })
+ } else {
+ toast.dismiss(toastId)
+ }
+ }, [hasChanges])
+
+ const handleProviderClick = async (p: typeof providers[0]) => {
+ if (!p.available) {
+ setSelectedProvider({
+ id: p.id,
+ name: p.name,
+ authMethod: p.authMethod,
+ loginCommand: p.loginCommand,
+ })
+ setProviderDropdownOpen(false)
+ if (p.authMethod === 'oauth') {
+ setOauthDialogOpen(true)
+ } else {
+ setApiKeyDialogOpen(true)
+ }
+ return
+ }
+ setSwitching(true)
+ setProviderDropdownOpen(false)
+ const result = await setProvider(p.id)
+ setSwitching(false)
+ if (!result.ok) {
+ toast.error('Failed to switch provider')
+ }
+ }
+
+ const handleModelSelect = async (model: string) => {
+ if (!model || model === current?.model || !current?.provider) return
+ setSwitching(true)
+ const result = await setProvider(current.provider, model)
+ setSwitching(false)
+ if (!result.ok) {
+ toast.error('Failed to switch model')
+ }
+ }
+
+ if (profileLoading || providerLoading) {
+ return (
+
+
+
+ )
+ }
+
+ const currentProvider = providers.find(p => p.id === current?.provider)
+
+ return (
+
+
+ {/* Page Header */}
+
+
Profile
+
+ Configure your agent's identity and the model that powers it.
+
+
+
+ {/* Model Section */}
+
+
+
Model
+
AI model for your agent
+
+
+
+ {/* Provider Row */}
+
+
+
Provider
+
LLM API connection
+
+
+
setProviderDropdownOpen(!providerDropdownOpen)}
+ disabled={switching}
+ >
+ {current?.available ? (
+
+ ) : (
+
+ )}
+ {current?.providerName ?? 'Select'}
+
+
+
+ {providerDropdownOpen && (
+
+ {providers.map((p) => (
+ handleProviderClick(p)}
+ disabled={switching}
+ >
+
+ {p.name}
+
+ ))}
+
+ )}
+
+
+
+ {/* Model Row - with Combobox */}
+ {currentProvider && (
+
+
+
Model
+
Select or enter model ID
+
+
{
+ if (value) handleModelSelect(value)
+ }}
+ disabled={switching}
+ >
+
+
+ No models found
+
+ {(model) => (
+
+ {model}
+
+ )}
+
+
+
+
+ )}
+
+
+ {!current?.available && (
+
+ Select a provider and add your API key to enable your agent.
+
+ )}
+
+
+ {/* Identity Section */}
+
+
+
Identity
+
How your agent presents itself
+
+
+
+
+
+
Agent Name
+
Personalize interactions
+
+
setName(e.target.value)}
+ placeholder="My Assistant"
+ className="h-8 w-48 text-sm"
+ />
+
+
+
+
+ {/* Personalization Section */}
+
+
+
Personalization
+
Help the agent understand you better
+
+
+
+
+
+
+ {/* Dialogs */}
+ {selectedProvider && selectedProvider.authMethod === 'api-key' && (
+
{
+ await refresh()
+ const result = await setProvider(selectedProvider.id)
+ if (!result.ok) {
+ console.error('Failed to switch provider:', result.error)
+ }
+ }}
+ />
+ )}
+
+ {selectedProvider && selectedProvider.authMethod === 'oauth' && (
+ {
+ await refresh()
+ const result = await setProvider(selectedProvider.id)
+ if (!result.ok) {
+ console.error('Failed to switch provider:', result.error)
+ }
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/pages/agent/skills.tsx b/apps/desktop/src/renderer/src/pages/agent/skills.tsx
new file mode 100644
index 00000000..7344aa2e
--- /dev/null
+++ b/apps/desktop/src/renderer/src/pages/agent/skills.tsx
@@ -0,0 +1,29 @@
+import { useSkillsStore } from '../../stores/skills'
+import { SkillList } from '../../components/skill-list'
+
+export default function SkillsPage() {
+ const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
+
+ return (
+
+
+ {/* Page Header */}
+
+
Skills
+
+ Skills are modular capabilities that expand what your agent can do.
+
+
+
+ {/* Content */}
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/pages/agent/tools.tsx b/apps/desktop/src/renderer/src/pages/agent/tools.tsx
new file mode 100644
index 00000000..bd942d1f
--- /dev/null
+++ b/apps/desktop/src/renderer/src/pages/agent/tools.tsx
@@ -0,0 +1,29 @@
+import { useToolsStore } from '../../stores/tools'
+import { ToolList } from '../../components/tool-list'
+
+export default function ToolsPage() {
+ const { tools, loading, error, toggleTool, refresh } = useToolsStore()
+
+ return (
+
+
+ {/* Page Header */}
+
+
Tools
+
+ Toggle tools to control what your agent can do.
+
+
+
+ {/* Content */}
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/pages/channels.tsx b/apps/desktop/src/renderer/src/pages/clients.tsx
similarity index 50%
rename from apps/desktop/src/renderer/src/pages/channels.tsx
rename to apps/desktop/src/renderer/src/pages/clients.tsx
index a5c5aa4d..88fe4494 100644
--- a/apps/desktop/src/renderer/src/pages/channels.tsx
+++ b/apps/desktop/src/renderer/src/pages/clients.tsx
@@ -9,7 +9,17 @@ import {
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Badge } from '@multica/ui/components/ui/badge'
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@multica/ui/components/ui/tabs'
+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'
+import { DeviceList } from '../components/device-list'
/** Status badge color mapping */
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
@@ -152,31 +162,173 @@ function TelegramCard() {
)
}
-export default function ChannelsPage() {
+function ChannelsTab() {
const { loading, error } = useChannelsStore()
- return (
-
-
- {/* Page Header */}
-
-
Channels
-
- Channels let you talk to your agent from other platforms like Telegram or Slack. Connect one to chat with your agent anywhere.
-
-
+ if (loading) {
+ return
Loading...
+ }
- {/* Configuration Area */}
-
- {loading ? (
-
Loading...
- ) : error ? (
-
{error}
- ) : (
-
- )}
-
-
+ if (error) {
+ return
{error}
+ }
+
+ return (
+
+
+ Connect messaging platforms to chat with your agent.
+
+
+
+ )
+}
+
+/** QR Code card with show/hide toggle */
+function QRCodeCard({
+ gateway,
+ hubId,
+ agentId,
+}: {
+ gateway: string
+ hubId: string
+ agentId: string
+}) {
+ const [expanded, setExpanded] = useState(true)
+
+ return (
+
+
+
+
+ Scan to Connect
+ Open Multica on your phone and scan.
+
+
setExpanded(!expanded)}>
+
+ {expanded ? 'Hide' : 'Show'}
+
+
+
+ {expanded && (
+
+
+
+ )}
+
+ )
+}
+
+/** Authorized devices card */
+function DevicesCard() {
+ return (
+
+
+ Authorized Devices
+ Devices you've approved to access your agent.
+
+
+
+
+
+ )
+}
+
+function MulticaAppTab() {
+ const { hubInfo, agents } = useHubStore()
+ const primaryAgent = selectPrimaryAgent(agents)
+
+ return (
+
+
+ Scan to connect from your phone. Manage authorized devices.
+
+
+
+
+
+
+
+ )
+}
+
+/** 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 (
+
+ {isConnecting ? (
+
+ ) : (
+
+ )}
+
+ {state === 'connecting' && 'Connecting to gateway...'}
+ {state === 'reconnecting' && 'Reconnecting to gateway...'}
+ {state === 'disconnected' && 'Gateway disconnected'}
+
+
+ {url}
+
+
+ )
+}
+
+export default function ClientsPage() {
+ return (
+
+
+ {/* Page Header */}
+
+
Clients
+
+ Access your agent from anywhere. Connect via third-party platforms or the Multica mobile app.
+
+
+
+
+
+
+ {/* Tabs */}
+
+
+
+
+ Channels
+
+
+
+ Multica App
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/desktop/src/renderer/src/pages/home.tsx b/apps/desktop/src/renderer/src/pages/home.tsx
index eca4959c..ef3c98a0 100644
--- a/apps/desktop/src/renderer/src/pages/home.tsx
+++ b/apps/desktop/src/renderer/src/pages/home.tsx
@@ -1,126 +1,22 @@
-import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
-import {
- Collapsible,
- CollapsibleTrigger,
- CollapsibleContent,
-} from '@multica/ui/components/ui/collapsible'
-import {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from '@multica/ui/components/ui/tooltip'
import {
Loader2,
- ChevronDown,
- Check,
- AlertCircle,
ArrowRight,
- QrCode,
- Pencil,
- Plug,
- Code,
- Share,
- Clock,
- Brain,
- RefreshCw,
+ Settings,
} from 'lucide-react'
-import { ConnectionQRCode } from '../components/qr-code'
-import { DeviceList } from '../components/device-list'
-import { AgentSettingsDialog } from '../components/agent-settings-dialog'
-import { ApiKeyDialog } from '../components/api-key-dialog'
-import { OAuthDialog } from '../components/oauth-dialog'
-import { useHubStore, selectPrimaryAgent } from '../stores/hub'
+import { useHubStore } from '../stores/hub'
import { useProviderStore } from '../stores/provider'
-import { useChannelsStore } from '../stores/channels'
-import { useSkillsStore, selectSkillStats } from '../stores/skills'
-import { useToolsStore } from '../stores/tools'
-import { useCronJobsStore } from '../stores/cron-jobs'
-import { toast } from '@multica/ui/components/ui/sonner'
import { cn } from '@multica/ui/lib/utils'
export default function HomePage() {
const navigate = useNavigate()
- const { hubInfo, agents, loading } = useHubStore()
- const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
- const { skills } = useSkillsStore()
- const { tools } = useToolsStore()
- const { states: channelStates } = useChannelsStore()
- const { jobs: cronJobs } = useCronJobsStore()
+ const { loading } = useHubStore()
+ const { current, loading: providerLoading } = useProviderStore()
- // Computed values
- const skillStats = selectSkillStats(skills)
-
- const [capabilitiesOpen, setCapabilitiesOpen] = useState(false)
- const [capabilitiesRefreshing, setCapabilitiesRefreshing] = useState(false)
-
- const [settingsOpen, setSettingsOpen] = useState(false)
- const [agentName, setAgentName] = useState
()
- const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)
- const [switching, setSwitching] = useState(false)
- const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
- const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
- const [qrCodeExpanded, setQrCodeExpanded] = useState(false)
- const [selectedProvider, setSelectedProvider] = useState<{
- id: string
- name: string
- authMethod: 'api-key' | 'oauth'
- loginCommand?: string
- } | null>(null)
- const dropdownRef = useRef(null)
-
- // Computed stats
- const enabledTools = tools.filter(t => t.enabled).length
- const connectedChannels = channelStates.filter(s => s.status === 'running').length
- const cronCount = cronJobs.length
-
- // Close dropdown when clicking outside
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
- setProviderDropdownOpen(false)
- }
- }
-
- if (providerDropdownOpen) {
- document.addEventListener('mousedown', handleClickOutside)
- }
-
- return () => {
- document.removeEventListener('mousedown', handleClickOutside)
- }
- }, [providerDropdownOpen])
-
- // Load agent profile info
- useEffect(() => {
- loadAgentInfo()
- }, [])
-
- // Reload agent info when settings dialog closes
- useEffect(() => {
- if (!settingsOpen) {
- loadAgentInfo()
- }
- }, [settingsOpen])
-
- const loadAgentInfo = async () => {
- try {
- const data = await window.electronAPI.profile.get()
- setAgentName(data.name)
- } catch (err) {
- console.error('Failed to load agent info:', err)
- }
- }
-
- // Get the first agent
- const primaryAgent = selectPrimaryAgent(agents)
-
- // Agent status: running if app is open, warning if no LLM provider
const isProviderAvailable = current?.available ?? false
const agentReady = !providerLoading && isProviderAvailable
- // Loading state (only while provider info is loading)
if (loading || providerLoading) {
return (
@@ -132,42 +28,17 @@ export default function HomePage() {
)
}
- // Refresh all capabilities
- const refreshCapabilities = async () => {
- setCapabilitiesRefreshing(true)
- try {
- await Promise.all([
- useSkillsStore.getState().refresh({ silent: true }),
- useToolsStore.getState().refresh({ silent: true }),
- useChannelsStore.getState().refresh({ silent: true }),
- useCronJobsStore.getState().refresh({ silent: true }),
- ])
- toast.success('Status refreshed')
- } catch (err) {
- // Individual store refresh errors are already toasted
- console.error('[HomePage] Failed to refresh capabilities:', err)
- } finally {
- setCapabilitiesRefreshing(false)
- }
- }
-
- // Build capability summary
- const capabilitySummary = `${skillStats.enabled} skills, ${enabledTools} tools, ${connectedChannels} channels, ${cronCount} scheduled tasks`
-
return (
-
-
- {/* Page Header */}
-
-
Dashboard
-
Overview of your agent's status and capabilities.
-
+
+
+ {/* Page Header */}
+
+
Dashboard
+
Overview of your agent's status.
+
- {/* Row 1: Status + Chat (Left) | Agent Settings (Right) */}
-
- {/* Left: Status + Chat */}
-
- {/* Status */}
+ {/* Status Section */}
+
{agentReady ? (
@@ -188,470 +59,42 @@ export default function HomePage() {
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
)}>
- {agentReady
- ? 'Your agent is running'
- : 'Configure LLM provider to start'}
+ {agentReady ? 'Your agent is running' : 'Configure LLM provider to start'}
{agentReady
? 'Ready to assist you. Start a conversation to get things done.'
- : 'Select an LLM provider on the right to enable your agent.'}
+ : 'Go to Agent settings to configure your LLM provider.'}
-
navigate('/chat')}
- disabled={!agentReady}
- >
- Start Chat
-
-
-
-
- {/* Vertical Divider */}
-
-
- {/* Right: Agent Settings (stacked vertically) */}
-
- {/* Agent Profile */}
-
-
Agent Profile
+
setSettingsOpen(true)}
+ size="lg"
+ className="gap-2"
+ onClick={() => navigate('/chat')}
+ disabled={!agentReady}
>
- {agentName || 'Unnamed Agent'}
-
-
-
-
- {/* LLM Provider */}
-
-
LLM Provider
-
setProviderDropdownOpen(!providerDropdownOpen)}
- disabled={providerLoading || switching}
- >
-
- {current?.available ? (
-
- ) : (
-
- )}
- {current?.providerName ?? 'Loading...'}
- ·
- {current?.model ?? '-'}
-
-
+ Start Chat
+
- {/* Provider Dropdown */}
- {providerDropdownOpen && (
-
-
- {providers.map((p) => (
- {
- if (!p.available) {
- setSelectedProvider({
- id: p.id,
- name: p.name,
- authMethod: p.authMethod,
- loginCommand: p.loginCommand,
- })
- setProviderDropdownOpen(false)
- if (p.authMethod === 'oauth') {
- setOauthDialogOpen(true)
- } else {
- setApiKeyDialogOpen(true)
- }
- return
- }
- setSwitching(true)
- setProviderDropdownOpen(false)
- const result = await setProvider(p.id)
- setSwitching(false)
- if (!result.ok) {
- console.error('Failed to switch provider:', result.error)
- }
- }}
- disabled={switching}
- >
-
-
- {p.id === 'claude-code' ? 'Claude Code' :
- p.id === 'openai-codex' ? 'Codex' :
- p.id === 'kimi-coding' ? 'Kimi' :
- p.id === 'anthropic' ? 'Anthropic' :
- p.id === 'openai' ? 'OpenAI' :
- p.id === 'openrouter' ? 'OpenRouter' :
- p.name.split(' ')[0]}
-
-
- ))}
-
-
- {/* Model List */}
- {(() => {
- const currentProvider = providers.find(p => p.id === current?.provider)
- if (!currentProvider || currentProvider.models.length <= 1) return null
- return (
-
-
- Models
-
-
- {currentProvider.models.map((model) => (
- {
- if (model === current?.model) return
- setSwitching(true)
- setProviderDropdownOpen(false)
- const result = await setProvider(currentProvider.id, model)
- setSwitching(false)
- if (!result.ok) {
- console.error('Failed to switch model:', result.error)
- }
- }}
- disabled={switching}
- >
-
- {model}
-
- ))}
-
-
- )
- })()}
-
+ {!agentReady && (
+
navigate('/agent/profile')}
+ >
+
+ Configure Agent
+
)}
-
- {/* Divider */}
-
-
- {/* Section 3: Capabilities (Collapsible) */}
-
-
-
-
- Your agent currently has {capabilitySummary}
-
-
- {capabilitiesRefreshing ? (
-
- ) : (
-
- )}
-
- Refresh status
-
-
-
- {capabilitiesOpen ? 'Hide' : 'Details'}
-
-
-
-
-
- {/* Skills */}
-
-
-
-
-
Skills ({skillStats.enabled})
-
-
navigate('/skills')}
- >
- View all
-
-
-
- {skillStats.enabled > 0 ? (
-
- {skills.filter(s => s.enabled).slice(0, 8).map((skill) => (
-
- {skill.name}
-
- ))}
- {skillStats.enabled > 8 && (
-
- +{skillStats.enabled - 8} more
-
- )}
-
- ) : (
-
No skills enabled
- )}
-
-
- {/* Tools */}
-
-
-
-
- Tools ({enabledTools})
-
-
navigate('/tools')}
- >
- View all
-
-
-
- {enabledTools > 0 ? (
-
- {tools.filter(t => t.enabled).slice(0, 8).map((tool) => (
-
- {tool.name}
-
- ))}
- {enabledTools > 8 && (
-
- +{enabledTools - 8} more
-
- )}
-
- ) : (
-
No tools enabled
- )}
-
-
- {/* Channels */}
-
-
-
-
- Channels ({connectedChannels})
-
-
navigate('/channels')}
- >
- View all
-
-
-
- {connectedChannels > 0 ? (
-
- {channelStates.filter(s => s.status === 'running').slice(0, 8).map((channel) => (
-
- {channel.channelId}/{channel.accountId}
-
- ))}
- {connectedChannels > 8 && (
-
- +{connectedChannels - 8} more
-
- )}
-
- ) : (
-
No channels connected
- )}
-
-
- {/* Cron Jobs */}
-
-
-
-
- Scheduled Tasks ({cronCount})
-
-
navigate('/crons')}
- >
- View all
-
-
-
- {cronCount > 0 ? (
-
- {cronJobs.slice(0, 8).map((job) => (
-
- {job.name}
-
- ))}
- {cronCount > 8 && (
-
- +{cronCount - 8} more
-
- )}
-
- ) : (
-
No scheduled tasks
- )}
-
-
-
-
- {/* Divider */}
-
-
- {/* Section 4: Multi-Device Access */}
-
-
- {/* Left: Connect */}
-
-
-
Control from Anywhere
- setQrCodeExpanded(!qrCodeExpanded)}
- >
-
- {qrCodeExpanded ? 'Hide' : 'Show'}
-
-
-
- Open Multica Web on your phone and scan. Operate your computer and use your agent remotely.
-
-
- {/* QR Code Container */}
-
- {qrCodeExpanded ? (
-
- ) : (
- 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"
- >
-
- Click to show QR code
-
- )}
-
-
-
- {/* Vertical Divider */}
-
-
- {/* Right: Authorized Devices */}
-
-
Authorized Devices
-
- Devices you've approved to access your agent.
-
-
-
-
-
-
-
-
- {/* Dialogs */}
-
-
- {selectedProvider && selectedProvider.authMethod === 'api-key' && (
-
{
- await refresh()
- const result = await setProvider(selectedProvider.id)
- if (!result.ok) {
- console.error('Failed to switch provider:', result.error)
- }
- }}
- />
- )}
-
- {selectedProvider && selectedProvider.authMethod === 'oauth' && (
- {
- await refresh()
- const result = await setProvider(selectedProvider.id)
- if (!result.ok) {
- console.error('Failed to switch provider:', result.error)
- }
- }}
- />
- )}
-
)
}
diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx
index 3a06a9eb..5129b0e2 100644
--- a/apps/desktop/src/renderer/src/pages/layout.tsx
+++ b/apps/desktop/src/renderer/src/pages/layout.tsx
@@ -1,26 +1,33 @@
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { MulticaIcon } from '@multica/ui/components/multica-icon'
+import {
+ Collapsible,
+ CollapsibleTrigger,
+ CollapsibleContent,
+} from '@multica/ui/components/ui/collapsible'
import {
Home,
MessageSquare,
- Puzzle,
- Wrench,
- Radio,
+ Users,
Clock,
ChevronLeft,
ChevronRight,
+ ChevronDown,
+ Bot,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarGroup,
- SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
@@ -31,23 +38,30 @@ import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
const mainNavItems = [
- { path: '/', label: 'Home', icon: Home },
+ { path: '/', label: 'Home', icon: Home, exact: true },
{ path: '/chat', label: 'Chat', icon: MessageSquare },
]
-const configNavItems = [
- { path: '/skills', label: 'Skills', icon: Puzzle },
- { path: '/tools', label: 'Tools', icon: Wrench },
- { path: '/channels', label: 'Channels', icon: Radio },
- { path: '/crons', label: 'Crons', icon: Clock },
+const agentSubItems = [
+ { path: '/agent/profile', label: 'Profile' },
+ { path: '/agent/skills', label: 'Skills' },
+ { path: '/agent/tools', label: 'Tools' },
+]
+
+const bottomNavItems = [
+ { path: '/clients', label: 'Clients', icon: Users },
+ { path: '/crons', label: 'Scheduled Tasks', icon: Clock },
]
// All nav items for header lookup
-const allNavItems = [...mainNavItems, ...configNavItems]
+const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact?: boolean }> = [
+ ...mainNavItems,
+ { path: '/agent', label: 'Agent', icon: Bot },
+ ...bottomNavItems,
+]
function NavigationButtons() {
const navigate = useNavigate()
- // useLocation() triggers re-render on route change so we can re-evaluate history state
useLocation()
const historyIdx = window.history.state?.idx ?? 0
@@ -84,27 +98,29 @@ function MainHeader() {
const location = useLocation()
const needsTrafficLightSpace = state === 'collapsed' || isMobile
- // Find current page info
- const currentPage = allNavItems.find((item) =>
- item.path === '/'
- ? location.pathname === '/'
- : location.pathname.startsWith(item.path)
- )
+ const currentPage = allNavItems.find((item) => {
+ if (item.exact) return location.pathname === item.path
+ return location.pathname.startsWith(item.path)
+ })
return (
-
- {/* Drag placeholder for traffic lights when sidebar is collapsed */}
+
-
+
+
+
- {/* Center: Current page */}
{currentPage && (
@@ -114,95 +130,120 @@ function MainHeader() {
)}
- {/* Right: Theme toggle */}
-
+
+
+
)
}
export default function Layout() {
const location = useLocation()
+ const isAgentActive = location.pathname.startsWith('/agent')
return (
- {/* Traffic light area with navigation */}
-
-
-
+
+
+
-
- {/* Brand */}
-
-
- Multica
-
+
+
+
+ Multica
+
- {/* Main navigation */}
-
-
- {mainNavItems.map((item) => {
- const isActive = item.path === '/'
- ? location.pathname === '/'
- : location.pathname.startsWith(item.path)
- return (
-
-
-
-
- {item.label}
-
-
+
+
+ {/* Main nav items */}
+ {mainNavItems.map((item) => {
+ const isActive = item.exact
+ ? location.pathname === item.path
+ : location.pathname.startsWith(item.path)
+ return (
+
+
+
+
+ {item.label}
+
+
+
+ )
+ })}
+
+ {/* Agent collapsible */}
+
+
+ }
+ >
+
+ Agent
+
+
+
+
+ {agentSubItems.map((item) => (
+
+ }
+ isActive={location.pathname === item.path}
+ >
+ {item.label}
+
+
+ ))}
+
+
- )
- })}
-
-
+
- {/* Configuration */}
-
- Agent Configuration
-
- {configNavItems.map((item) => {
- const isActive = location.pathname.startsWith(item.path)
- return (
-
-
-
-
- {item.label}
-
-
-
- )
- })}
-
-
-
-
+ {/* Bottom nav items */}
+ {bottomNavItems.map((item) => {
+ const isActive = location.pathname.startsWith(item.path)
+ return (
+
+
+
+
+ {item.label}
+
+
+
+ )
+ })}
+
+
+
+
-
-
-
- {/* Main Content */}
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/desktop/src/renderer/src/pages/skills.tsx b/apps/desktop/src/renderer/src/pages/skills.tsx
deleted file mode 100644
index 778ff386..00000000
--- a/apps/desktop/src/renderer/src/pages/skills.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useSkillsStore } from '../stores/skills'
-import { SkillList } from '../components/skill-list'
-
-export default function SkillsPage() {
- const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
-
- return (
-
-
- {/* Page Header */}
-
-
Skills
-
- Skills are modular capabilities that expand what your agent can do. You can also ask your agent to create new skills for you.
-
-
-
- {/* Configuration Area */}
-
-
-
-
-
- )
-}
diff --git a/apps/desktop/src/renderer/src/pages/tools.tsx b/apps/desktop/src/renderer/src/pages/tools.tsx
deleted file mode 100644
index 19c7e96d..00000000
--- a/apps/desktop/src/renderer/src/pages/tools.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useToolsStore } from '../stores/tools'
-import { ToolList } from '../components/tool-list'
-
-export default function ToolsPage() {
- const { tools, loading, error, toggleTool, refresh } = useToolsStore()
-
- return (
-
-
- {/* Page Header */}
-
-
Tools
-
- Tools are actions your agent can perform, like reading files, searching the web, or running code. Toggle them to control what your agent can do.
-
-
-
- {/* Configuration Area */}
-
-
-
-
-
- )
-}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 69ea8aae..660b3dbc 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,10 +1,23 @@
import type { Metadata } from "next";
-import "@multica/ui/fonts";
+import { Geist_Mono, Inter, Playfair_Display } from "next/font/google";
import "@multica/ui/globals.css";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { ServiceWorkerRegister } from "./sw-register";
+const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+const playfair = Playfair_Display({
+ variable: "--font-brand",
+ subsets: ["latin"],
+ weight: ["400"],
+});
+
export const metadata: Metadata = {
title: "Multica",
description: "Distributed AI agent framework",
@@ -24,8 +37,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
+
+
+ )
+}
+
+const tabsListVariants = cva(
+ "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/packages/ui/src/styles/fonts.ts b/packages/ui/src/styles/fonts.ts
index a9694b2b..f2ed41b0 100644
--- a/packages/ui/src/styles/fonts.ts
+++ b/packages/ui/src/styles/fonts.ts
@@ -1,11 +1,5 @@
-// Unified font imports for Web and Desktop
-// Using fontsource for consistent cross-platform font loading
-
-// Geist Sans - Primary UI font
-import '@fontsource/geist-sans/400.css'
-import '@fontsource/geist-sans/500.css'
-import '@fontsource/geist-sans/600.css'
-import '@fontsource/geist-sans/700.css'
+// Font imports for Desktop (Electron)
+// Web uses next/font/google instead
// Geist Mono - Code font
import '@fontsource/geist-mono/400.css'
@@ -13,5 +7,5 @@ import '@fontsource/geist-mono/500.css'
import '@fontsource/geist-mono/600.css'
import '@fontsource/geist-mono/700.css'
-// Playfair Display - Brand font
-import '@fontsource-variable/playfair-display'
+// Note: Geist Sans removed - Desktop uses system fonts for CJK support
+// Note: Playfair Display loaded via Google Fonts in index.html
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index 4faacdc9..7c7f1cf1 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -18,24 +18,14 @@
* TYPOGRAPHY
* =============================================================================
*
- * Font Stack (loaded via @fontsource, works across Web + Desktop):
+ * Fonts are loaded via CSS variables injected by each app:
+ * - Web (Next.js): Uses next/font/google in layout.tsx
+ * - Desktop (Electron): Uses @fontsource packages
*
- * | Font | Variable | Usage |
- * |---------------------------|---------------|------------------------------|
- * | Geist Sans | font-sans | Primary UI text, body copy |
- * | Geist Mono | font-mono | Code, technical values |
- * | Playfair Display Variable | font-brand | Brand name "Multica" only |
- *
- * Why Geist?
- * - Created by Vercel, optimized for UI/developer tools
- * - Excellent legibility at small sizes (12-14px)
- * - Neutral, professional appearance — not "AI-ish" or trendy
- * - Variable font = smaller bundle, flexible weights
- *
- * Why Playfair Display for brand?
- * - Contrast with Geist creates clear hierarchy
- * - Serif adds warmth/personality to otherwise minimal UI
- * - Used ONLY for "Multica" text — nowhere else
+ * Variables used:
+ * - --font-sans: Primary UI text (Inter/system font)
+ * - --font-geist-mono: Code and technical values
+ * - --font-brand: Brand name "Multica" only (Playfair Display)
*
* =============================================================================
* COLOR SYSTEM
@@ -93,9 +83,9 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
- --font-mono: 'Geist Mono', ui-monospace, monospace;
- --font-brand: 'Playfair Display Variable', serif;
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-geist-mono);
+ --font-brand: var(--font-brand);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -135,14 +125,6 @@
}
:root {
- /* =========================================================================
- * FONTS — unified across Web (Next.js) and Desktop (Electron/Vite)
- * Loaded via @fontsource packages in packages/ui/src/styles/fonts.ts
- * ========================================================================= */
- --font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
- --font-mono: 'Geist Mono', ui-monospace, monospace;
- --font-brand: 'Playfair Display Variable', serif;
-
/* =========================================================================
* COLORS — Light mode
* Using OKLCH for perceptual uniformity across the palette
@@ -275,7 +257,7 @@
}
@utility container {
- @apply w-full max-w-4xl mx-auto;
+ @apply w-full max-w-5xl mx-auto;
}
@layer base {