Merge pull request #138 from multica-ai/refactor/desktop-page-restructure

refactor(desktop): restructure pages and add dev reset scripts
This commit is contained in:
Naiyuan Qing 2026-02-12 16:45:07 +08:00 committed by GitHub
commit bad4d05c15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1274 additions and 1195 deletions

23
apps/desktop/.env.example Normal file
View file

@ -0,0 +1,23 @@
# =============================================================================
# Multica Desktop Environment Configuration
# =============================================================================
#
# Copy this file to create environment-specific configs:
# .env.development - Local development (npm run dev)
# .env.staging - Test environment (npm run build --mode staging)
# .env.production - Production release (npm run build)
#
# Variable naming convention:
# MAIN_VITE_* - Available in main process only
# RENDERER_VITE_* - Available in renderer process only
# VITE_* - Available in all processes
#
# =============================================================================
# Gateway WebSocket server URL
# Used for Hub <-> Gateway communication
MAIN_VITE_GATEWAY_URL=http://localhost:3000
# Multica API server URL
# Used for HTTP API requests (auth, updates, etc.)
MAIN_VITE_MULTICA_URL=http://localhost:3001

View file

@ -1,5 +1,16 @@
/// <reference types="vite-plugin-electron/electron-env" />
// Environment variables loaded from .env files
// See: .env.example, .env.development, .env.staging, .env.production
interface ImportMetaEnv {
readonly MAIN_VITE_GATEWAY_URL: string
readonly MAIN_VITE_MULTICA_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare namespace NodeJS {
interface ProcessEnv {
/**

View file

@ -30,11 +30,22 @@ function maskToken(token: unknown): string | undefined {
export function registerChannelsIpcHandlers(): void {
/**
* List all channel account states (running / stopped / error).
* Waits for any "starting" status to settle before returning.
*/
ipcMain.handle('channels:listStates', async () => {
const hub = getCurrentHub()
if (!hub) return []
return hub.channelManager.listAccountStates()
let states = hub.channelManager.listAccountStates()
// Wait for "starting" status to settle (max 3.5s, since internal timeout is 3s)
const start = Date.now()
while (states.some(s => s.status === 'starting') && Date.now() - start < 3500) {
await new Promise(r => setTimeout(r, 100))
states = hub.channelManager.listAccountStates()
}
return states
})
/**

View file

@ -39,7 +39,7 @@ export async function initializeHub(): Promise<void> {
return
}
const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000'
const gatewayUrl = import.meta.env.MAIN_VITE_GATEWAY_URL
safeLog(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`)
hub = new Hub(gatewayUrl)
@ -62,7 +62,7 @@ export async function initializeHub(): Promise<void> {
*/
function getHub(): Hub {
if (!hub) {
const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000'
const gatewayUrl = import.meta.env.MAIN_VITE_GATEWAY_URL
safeLog(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`)
hub = new Hub(gatewayUrl)
}

View file

@ -102,10 +102,10 @@ export function registerSkillsIpcHandlers(): void {
id: skill.id,
name: skill.name,
description: skill.description,
version: '1.0.0', // Skills don't have version in current implementation
version: '1.0.0',
enabled: skill.eligible,
source: skill.source as 'bundled' | 'global' | 'profile',
triggers: [`/${skill.id}`], // Default trigger is /<skill-id>
triggers: [`/${skill.id}`],
}))
console.log(`[IPC] skills:list - Returning ${skills.length} skills from agent`)

View file

@ -9,6 +9,9 @@
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
/* Use system fonts for proper CJK punctuation support */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-geist-mono: "Geist Mono", ui-monospace, monospace;
--font-brand: "Playfair Display", serif;
}
</style>

View file

@ -6,9 +6,10 @@ import { Toaster } from './components/toaster'
import Layout from './pages/layout'
import HomePage from './pages/home'
import ChatPage from './pages/chat'
import ToolsPage from './pages/tools'
import SkillsPage from './pages/skills'
import ChannelsPage from './pages/channels'
import ProfilePage from './pages/agent/profile'
import SkillsPage from './pages/agent/skills'
import ToolsPage from './pages/agent/tools'
import ClientsPage from './pages/clients'
import CronsPage from './pages/crons'
import OnboardingPage from './pages/onboarding'
import { useOnboardingStore } from './stores/onboarding'
@ -43,9 +44,10 @@ const router = createHashRouter([
),
},
{ path: 'chat', element: <ChatPage /> },
{ path: 'tools', element: <ToolsPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'channels', element: <ChannelsPage /> },
{ path: 'agent/profile', element: <ProfilePage /> },
{ path: 'agent/skills', element: <SkillsPage /> },
{ path: 'agent/tools', element: <ToolsPage /> },
{ path: 'clients', element: <ClientsPage /> },
{ path: 'crons', element: <CronsPage /> },
],
},
@ -56,14 +58,12 @@ export default function App() {
const setCompleted = useOnboardingStore((s) => s.setCompleted)
useEffect(() => {
// Load onboarding state from file system
async function hydrateOnboardingState() {
try {
const completed = await window.electronAPI.appState.getOnboardingCompleted()
setCompleted(completed)
} catch (err) {
console.error('[App] Failed to load onboarding state:', err)
// Default to false if load fails
setCompleted(false)
} finally {
setIsHydrated(true)
@ -72,7 +72,6 @@ export default function App() {
hydrateOnboardingState()
// Initialize hub and prefetch global data at app startup
useHubStore.getState().init()
useProviderStore.getState().fetch()
useChannelsStore.getState().fetch()
@ -81,7 +80,6 @@ export default function App() {
useCronJobsStore.getState().fetch()
}, [setCompleted])
// Show nothing while loading onboarding state to prevent flash
if (!isHydrated) {
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">

View file

@ -97,11 +97,9 @@ export function DeviceList() {
if (devices.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="flex flex-col items-center justify-center text-center py-8">
<Smartphone className="size-8 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No devices connected yet.
</p>
<p className="text-sm text-muted-foreground">No devices connected yet.</p>
</div>
)
}

View file

@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Button } from '@multica/ui/components/ui/button'
import { RefreshCw, CheckCircle, Copy } from 'lucide-react'
import { Copy, Check } from 'lucide-react'
// ============ Types ============
export interface QRCodeData {
type: 'multica-connect'
@ -16,51 +18,177 @@ export interface ConnectionQRCodeProps {
gateway: string
hubId: string
agentId: string
/** QR code expiry time in seconds (default: 30) */
expirySeconds?: number
/** Size of the QR code in pixels (default: 180) */
size?: number
/** Callback when token is refreshed */
onRefresh?: (data: QRCodeData) => void
}
/**
* Generate a secure random token for QR code authentication
*/
// ============ Hooks ============
/** Generate a secure random token */
function generateToken(): string {
return crypto.randomUUID()
}
/**
* ConnectionQRCode - A QR code component for sharing Agent connection info
* Hook to manage QR token lifecycle
* - Generates token on mount
* - Auto-refreshes when expired
* - Registers token with Hub
*/
function useQRToken(agentId: string, expirySeconds: number) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
const refresh = useCallback(() => {
const newToken = generateToken()
const newExpiry = Date.now() + expirySeconds * 1000
setToken(newToken)
setExpiresAt(newExpiry)
window.electronAPI?.hub.registerToken(newToken, agentId, newExpiry)
}, [agentId, expirySeconds])
// Register initial token
useEffect(() => {
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { token, expiresAt, refresh }
}
/**
* Hook for countdown timer
* Returns remaining seconds, auto-updates every second
*/
function useCountdown(expiresAt: number, onExpire: () => void) {
const [remaining, setRemaining] = useState(() =>
Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
)
const onExpireRef = useRef(onExpire)
onExpireRef.current = onExpire
useEffect(() => {
// Reset when expiresAt changes
setRemaining(Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)))
const id = setInterval(() => {
const next = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000))
setRemaining(next)
if (next === 0) onExpireRef.current()
}, 1000)
return () => clearInterval(id)
}, [expiresAt])
return remaining
}
/**
* Hook for clipboard copy with feedback
*/
function useCopyToClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false)
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), timeout)
return true
} catch {
return false
}
}, [timeout])
return { copied, copy }
}
// ============ Components ============
/** Corner accent decoration */
function CornerAccent({ position }: { position: 'tl' | 'tr' | 'bl' | 'br' }) {
const positionClasses = {
tl: '-top-2 -left-2 border-t-2 border-l-2 rounded-tl-lg',
tr: '-top-2 -right-2 border-t-2 border-r-2 rounded-tr-lg',
bl: '-bottom-2 -left-2 border-b-2 border-l-2 rounded-bl-lg',
br: '-bottom-2 -right-2 border-b-2 border-r-2 rounded-br-lg',
}
return (
<div
className={`absolute w-5 h-5 border-muted-foreground/30 ${positionClasses[position]}`}
/>
)
}
/** QR code frame with corner accents */
function QRCodeFrame({ children }: { children: React.ReactNode }) {
return (
<div className="relative inline-block">
<CornerAccent position="tl" />
<CornerAccent position="tr" />
<CornerAccent position="bl" />
<CornerAccent position="br" />
<div className="bg-white p-3 rounded-lg">{children}</div>
</div>
)
}
/** Format seconds as M:SS */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
/** Expiry timer display */
function ExpiryTimer({ remaining }: { remaining: number }) {
// Derive display state from remaining seconds (no extra state needed)
const isWarning = remaining > 0 && remaining < 10
return (
<span
className={`text-xs font-mono ${
isWarning ? 'text-orange-500' : 'text-muted-foreground'
}`}
>
Expires in {formatTime(remaining)}
</span>
)
}
/** Copy link button */
function CopyLinkButton({ url }: { url: string }) {
const { copied, copy } = useCopyToClipboard()
return (
<Button variant="ghost" size="sm" className="h-7 gap-1.5" onClick={() => copy(url)}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? 'Copied!' : 'Copy Link'}
</Button>
)
}
// ============ Main Component ============
/**
* ConnectionQRCode - QR code for mobile app connection
*
* Features:
* - Generates time-limited tokens for secure connections
* - Countdown timer showing expiry time
* - Refresh button to generate new token
* - Copy link button for manual sharing
* - Decorative corner accents for visual polish
* Architecture:
* - useQRToken: manages token generation and Hub registration
* - useCountdown: handles timer with auto-refresh on expiry
* - Pure child components for display (no state)
*/
export function ConnectionQRCode({
gateway,
hubId,
agentId,
expirySeconds = 30,
size = 180,
onRefresh,
size = 200,
}: ConnectionQRCodeProps) {
const [token, setToken] = useState(generateToken)
const [expiresAt, setExpiresAt] = useState(() => Date.now() + expirySeconds * 1000)
const [remainingSeconds, setRemainingSeconds] = useState(expirySeconds)
const [copied, setCopied] = useState(false)
const { token, expiresAt, refresh } = useQRToken(agentId, expirySeconds)
const remaining = useCountdown(expiresAt, refresh)
// Register initial token with Hub on mount
useEffect(() => {
window.electronAPI?.hub.registerToken(token, agentId, expiresAt)
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on mount
}, [])
// QR code data payload
// Derive QR data and URL from current token (computed during render)
const qrData: QRCodeData = useMemo(
() => ({
type: 'multica-connect',
@ -73,7 +201,6 @@ export function ConnectionQRCode({
[gateway, hubId, agentId, token, expiresAt]
)
// URL format for the connection
const connectionUrl = useMemo(() => {
const params = new URLSearchParams({
gateway,
@ -85,130 +212,22 @@ export function ConnectionQRCode({
return `multica://connect?${params.toString()}`
}, [gateway, hubId, agentId, token, expiresAt])
// Refresh token handler
const handleRefresh = useCallback(() => {
const newToken = generateToken()
const newExpires = Date.now() + expirySeconds * 1000
setToken(newToken)
setExpiresAt(newExpires)
setRemainingSeconds(expirySeconds)
// Register new token with Hub for verification
window.electronAPI?.hub.registerToken(newToken, agentId, newExpires)
if (onRefresh) {
onRefresh({
type: 'multica-connect',
gateway,
hubId,
agentId,
token: newToken,
expires: newExpires,
})
}
}, [gateway, hubId, agentId, expirySeconds, onRefresh])
// Countdown timer
useEffect(() => {
const timer = setInterval(() => {
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000))
setRemainingSeconds(remaining)
// Auto-refresh when expired
if (remaining === 0) {
handleRefresh()
}
}, 1000)
return () => clearInterval(timer)
}, [expiresAt, handleRefresh])
// Copy link handler
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(connectionUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy link:', err)
}
}
// Format remaining time as M:SS
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
// Warning state when less than 1 minute remaining
const isExpiringSoon = remainingSeconds < 60 && remainingSeconds > 0
const isExpired = remainingSeconds === 0
return (
<div className="flex flex-col items-center">
{/* QR Code with decorative corners */}
<div className="relative">
{/* Corner accents */}
<div className="absolute -top-3 -left-3 w-6 h-6 border-t-2 border-l-2 border-primary/60 rounded-tl-lg" />
<div className="absolute -top-3 -right-3 w-6 h-6 border-t-2 border-r-2 border-primary/60 rounded-tr-lg" />
<div className="absolute -bottom-3 -left-3 w-6 h-6 border-b-2 border-l-2 border-primary/60 rounded-bl-lg" />
<div className="absolute -bottom-3 -right-3 w-6 h-6 border-b-2 border-r-2 border-primary/60 rounded-br-lg" />
<div className="flex flex-col items-center gap-4">
<QRCodeFrame>
<QRCodeSVG
value={JSON.stringify(qrData)}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</QRCodeFrame>
{/* QR Code */}
<div className="bg-white p-4 rounded-xl shadow-lg">
<QRCodeSVG
value={JSON.stringify(qrData)}
size={size}
level="M"
marginSize={0}
bgColor="#ffffff"
fgColor="#0a0a0a"
/>
</div>
{/* Expired overlay */}
{isExpired && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm rounded-xl flex items-center justify-center">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="size-4 mr-2" />
Refresh
</Button>
</div>
)}
</div>
{/* Info section */}
<div className="mt-4 space-y-2">
{/* Expiry timer */}
<div className="flex items-center gap-2">
<span
className={`text-xs font-mono ${
isExpiringSoon
? 'text-orange-500 dark:text-orange-400'
: isExpired
? 'text-red-500 dark:text-red-400'
: 'text-muted-foreground'
}`}
>
{isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`}
</span>
<Button
variant="ghost"
size="sm"
onClick={isExpired ? handleRefresh : handleCopyLink}
>
{isExpired ? (
<RefreshCw className="size-3.5 mr-1" />
) : copied ? (
<CheckCircle className="size-3.5 mr-1" />
) : (
<Copy className="size-3.5 mr-1" />
)}
{isExpired ? 'Refresh' : (copied ? 'Copied!' : 'Copy Link')}
</Button>
</div>
<div className="flex items-center gap-3">
<ExpiryTimer remaining={remaining} />
<CopyLinkButton url={connectionUrl} />
</div>
</div>
)

View file

@ -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<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',
@ -36,23 +28,21 @@ export function SkillList({
skills,
loading,
error,
onToggleSkill,
onRefresh,
}: SkillListProps) {
// Track toggling state for individual skills
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set())
// Track which skills are expanded
const [expandedSkills, setExpandedSkills] = 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)
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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading skills...</span>
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
@ -78,20 +67,20 @@ export function SkillList({
<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>
<p className="text-sm text-muted-foreground">
{skills.length} skill{skills.length !== 1 && 's'} available
</p>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={loading}
className="gap-1.5"
className="gap-1.5 h-8"
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCw className="size-4" />
<RotateCw className="size-3.5" />
)}
Refresh
</Button>
@ -110,94 +99,53 @@ export function SkillList({
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)
<div key={source}>
{/* Section header */}
<h3 className="text-sm font-medium mb-3">{SOURCE_TITLES[source]}</h3>
{/* Skills card */}
<div className="rounded-lg border bg-card">
{sourceSkills.map((skill, index) => {
const isExpanded = expandedSkills.has(skill.id)
const isLast = index === sourceSkills.length - 1
return (
<div
<Collapsible
key={skill.id}
className="flex items-center justify-between px-4 py-3 rounded-lg border hover:bg-muted/30 transition-colors"
open={isExpanded}
onOpenChange={() => toggleSkill(skill.id)}
>
{/* 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>
<CollapsibleTrigger
className={cn(
'w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left',
!isLast && !isExpanded && 'border-b'
)}
</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'
}`}
>
{skill.enabled ? (
<CheckCircle className="size-4" />
) : (
<X className="size-4" />
>
<span className="text-sm font-medium flex-1">{skill.name}</span>
<code className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono">
{skill.triggers[0] || `/${skill.id}`}
</code>
<span className="text-xs text-muted-foreground">
{skill.version}
</span>
<ChevronRight
className={cn(
'size-4 text-muted-foreground transition-transform flex-shrink-0',
isExpanded && 'rotate-90'
)}
<span className="text-xs font-medium">
{skill.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
{/* Right: Toggle */}
<div className="flex items-center gap-2">
{isToggling && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill.id)}
disabled={isToggling}
/>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={cn(
'text-sm text-muted-foreground p-4',
!isLast && 'border-b'
)}
>
{skill.description || 'No description'}
</div>
</CollapsibleContent>
</Collapsible>
)
})}
</div>
@ -207,15 +155,10 @@ export function SkillList({
{/* Empty state */}
{skills.length === 0 && !loading && (
<div className="text-center py-12 text-muted-foreground">
<p>No skills found.</p>
<div className="text-center py-12 text-muted-foreground text-sm">
No skills found.
</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>
)
}

View file

@ -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<string, string> = {
other: 'Other',
}
// Group descriptions
const GROUP_DESCRIPTIONS: Record<string, string> = {
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<string, typeof FolderOpen> = {
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 (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading tools...</span>
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</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">
{/* Header */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
</div>
</p>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="gap-1.5"
disabled={loading}
className="gap-1.5 h-8"
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCw className="size-4" />
<RotateCw className="size-3.5" />
)}
Refresh
</Button>
@ -138,86 +153,83 @@ export function ToolList({
)}
{/* Tool groups */}
<div className="space-y-2">
{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 (
<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">
<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
return (
<div key={group.id}>
{/* Section header */}
<div className="mb-3 flex items-start gap-2">
<GroupIcon className="size-4 text-muted-foreground mt-0.5" />
<div>
<h3 className="text-sm font-medium">{group.name}</h3>
<p className="text-xs text-muted-foreground">
{group.enabledCount}/{group.totalCount} enabled
</p>
</div>
</div>
{/* Tools card */}
<Collapsible open={isExpanded} onOpenChange={() => toggleGroup(group.id)}>
<div className="rounded-lg border bg-card">
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors text-left">
<span className="text-sm text-muted-foreground">
{group.description}
</span>
</div>
{isExpanded ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
<ChevronRight
className={cn(
'size-4 text-muted-foreground transition-transform flex-shrink-0',
isExpanded && 'rotate-90'
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t">
{group.tools.map((tool, index) => {
const isToggling = togglingTools.has(tool.name)
const isLast = index === group.tools.length - 1
{/* 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>
return (
<div
key={tool.name}
className={cn(
'flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors',
!isLast && 'border-b'
)}
>
<div className="min-w-0 flex-1">
<code className="text-sm font-mono">{tool.name}</code>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{tool.description}
</p>
)}
</div>
{tool.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{tool.description}
</p>
)}
<div className="flex items-center gap-2 ml-4">
{isToggling && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
<Switch
checked={tool.enabled}
onCheckedChange={() => handleToggleTool(tool.name)}
disabled={isToggling}
/>
</div>
</div>
<div className="flex items-center gap-2">
{isToggling && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={tool.enabled}
onCheckedChange={() => handleToggleTool(tool.name)}
disabled={isToggling}
/>
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
)
})}
{/* Note about persistence */}
<p className="text-xs text-muted-foreground text-center">
Changes are saved automatically and apply to the running Agent immediately.
{/* Note */}
<p className="text-xs text-muted-foreground">
Changes apply immediately to the running Agent.
</p>
</div>
)

View file

@ -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<HTMLDivElement>(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 (
<div className="h-full flex items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
const currentProvider = providers.find(p => p.id === current?.provider)
return (
<div className="h-full overflow-auto">
<div className="container p-6">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-lg font-medium">Profile</h1>
<p className="text-sm text-muted-foreground">
Configure your agent's identity and the model that powers it.
</p>
</div>
{/* Model Section */}
<section className="mb-8">
<div className="mb-3">
<h2 className="text-sm font-medium">Model</h2>
<p className="text-xs text-muted-foreground">AI model for your agent</p>
</div>
<div className="rounded-lg border bg-card">
{/* Provider Row */}
<div className="flex items-center justify-between px-4 py-3 border-b" ref={dropdownRef}>
<div>
<div className="text-sm font-medium">Provider</div>
<div className="text-xs text-muted-foreground">LLM API connection</div>
</div>
<div className="relative">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={switching}
>
{current?.available ? (
<Check className="size-3 text-green-500" />
) : (
<AlertCircle className="size-3 text-yellow-500" />
)}
<span>{current?.providerName ?? 'Select'}</span>
<ChevronDown className={cn(
'size-3.5 text-muted-foreground transition-transform',
providerDropdownOpen && 'rotate-180'
)} />
</Button>
{providerDropdownOpen && (
<div className="absolute right-0 top-full mt-1 z-10 bg-popover border border-border rounded-lg shadow-lg p-1.5 min-w-[200px]">
{providers.map((p) => (
<button
key={p.id}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded text-left text-sm transition-colors',
p.id === current?.provider
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50',
!p.available && 'opacity-50'
)}
onClick={() => handleProviderClick(p)}
disabled={switching}
>
<span className={cn(
'size-2 rounded-full flex-shrink-0',
p.available ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
<span>{p.name}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Model Row - with Combobox */}
{currentProvider && (
<div className="flex items-center justify-between px-4 py-3">
<div>
<div className="text-sm font-medium">Model</div>
<div className="text-xs text-muted-foreground">Select or enter model ID</div>
</div>
<Combobox
items={currentProvider?.models ?? []}
value={current?.model ?? ''}
onValueChange={(value) => {
if (value) handleModelSelect(value)
}}
disabled={switching}
>
<ComboboxInput
placeholder="Select model"
className="w-48 h-8 text-sm font-mono"
/>
<ComboboxContent>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(model) => (
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)}
</div>
{!current?.available && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
Select a provider and add your API key to enable your agent.
</p>
)}
</section>
{/* Identity Section */}
<section className="mb-8">
<div className="mb-3">
<h2 className="text-sm font-medium">Identity</h2>
<p className="text-xs text-muted-foreground">How your agent presents itself</p>
</div>
<div className="rounded-lg border bg-card">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex-1 mr-4">
<div className="text-sm font-medium mb-1">Agent Name</div>
<div className="text-xs text-muted-foreground">Personalize interactions</div>
</div>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Assistant"
className="h-8 w-48 text-sm"
/>
</div>
</div>
</section>
{/* Personalization Section */}
<section className="mb-8">
<div className="mb-3">
<h2 className="text-sm font-medium">Personalization</h2>
<p className="text-xs text-muted-foreground">Help the agent understand you better</p>
</div>
<Textarea
value={userContent}
onChange={(e) => setUserContent(e.target.value)}
placeholder="- I'm a frontend developer&#10;- I prefer TypeScript&#10;- Please respond in Chinese"
className="min-h-[120px] font-mono text-sm"
/>
</section>
</div>
{/* Dialogs */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
</div>
)
}

View file

@ -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 (
<div className="h-full overflow-auto">
<div className="container p-6">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-lg font-medium">Skills</h1>
<p className="text-sm text-muted-foreground">
Skills are modular capabilities that expand what your agent can do.
</p>
</div>
{/* Content */}
<SkillList
skills={skills}
loading={loading}
error={error}
onToggleSkill={toggleSkill}
onRefresh={refresh}
/>
</div>
</div>
)
}

View file

@ -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 (
<div className="h-full overflow-auto">
<div className="container p-6">
{/* Page Header */}
<div className="mb-8">
<h1 className="text-lg font-medium">Tools</h1>
<p className="text-sm text-muted-foreground">
Toggle tools to control what your agent can do.
</p>
</div>
{/* Content */}
<ToolList
tools={tools}
loading={loading}
error={error}
onToggleTool={toggleTool}
onRefresh={refresh}
/>
</div>
</div>
)
}

View file

@ -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 (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Channels</h1>
<p className="text-sm text-muted-foreground">
Channels let you talk to your agent from other platforms like Telegram or Slack. Connect one to chat with your agent anywhere.
</p>
</div>
if (loading) {
return <p className="text-sm text-muted-foreground">Loading...</p>
}
{/* Configuration Area */}
<div className="flex-1 min-h-0">
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<TelegramCard />
)}
</div>
</div>
if (error) {
return <p className="text-sm text-destructive">{error}</p>
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Connect messaging platforms to chat with your agent.
</p>
<TelegramCard />
</div>
)
}
/** QR Code card with show/hide toggle */
function QRCodeCard({
gateway,
hubId,
agentId,
}: {
gateway: string
hubId: string
agentId: string
}) {
const [expanded, setExpanded] = useState(true)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Scan to Connect</CardTitle>
<CardDescription>Open Multica on your phone and scan.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={() => setExpanded(!expanded)}>
<QrCode className="size-4 mr-1.5" />
{expanded ? 'Hide' : 'Show'}
</Button>
</div>
</CardHeader>
{expanded && (
<CardContent className="flex justify-center">
<ConnectionQRCode
gateway={gateway}
hubId={hubId}
agentId={agentId}
expirySeconds={30}
size={200}
/>
</CardContent>
)}
</Card>
)
}
/** Authorized devices card */
function DevicesCard() {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Authorized Devices</CardTitle>
<CardDescription>Devices you've approved to access your agent.</CardDescription>
</CardHeader>
<CardContent>
<DeviceList />
</CardContent>
</Card>
)
}
function MulticaAppTab() {
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Scan to connect from your phone. Manage authorized devices.
</p>
<div className="space-y-6">
<QRCodeCard
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id ?? 'unknown'}
/>
<DevicesCard />
</div>
</div>
)
}
/** Gateway status indicator - only shows when disconnected/error */
function GatewayStatus() {
const { hubInfo } = useHubStore()
const state = hubInfo?.connectionState ?? 'disconnected'
const url = hubInfo?.url ?? 'Unknown'
// Only show when not connected
const isConnected = state === 'connected' || state === 'registered'
if (isConnected) return null
const isConnecting = state === 'connecting' || state === 'reconnecting'
return (
<div className="flex items-center gap-2 text-sm rounded-md bg-destructive/10 text-destructive px-3 py-2">
{isConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<WifiOff className="size-4" />
)}
<span>
{state === 'connecting' && 'Connecting to gateway...'}
{state === 'reconnecting' && 'Reconnecting to gateway...'}
{state === 'disconnected' && 'Gateway disconnected'}
</span>
<span className="text-destructive/60 font-mono text-xs truncate max-w-[200px]" title={url}>
{url}
</span>
</div>
)
}
export default function ClientsPage() {
return (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Clients</h1>
<p className="text-sm text-muted-foreground">
Access your agent from anywhere. Connect via third-party platforms or the Multica mobile app.
</p>
<div className="mt-2">
<GatewayStatus />
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="channels" className="flex-1">
<TabsList className="mb-4">
<TabsTrigger value="channels" className="gap-2">
<Radio className="size-4" />
Channels
</TabsTrigger>
<TabsTrigger value="app" className="gap-2">
<Smartphone className="size-4" />
Multica App
</TabsTrigger>
</TabsList>
<TabsContent value="channels">
<ChannelsTab />
</TabsContent>
<TabsContent value="app">
<MulticaAppTab />
</TabsContent>
</Tabs>
</div>
</div>
)
}

View file

@ -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<string | undefined>()
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<HTMLDivElement>(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 (
<div className="h-full flex items-center justify-center">
@ -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 (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6 h-full">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Dashboard</h1>
<p className="text-sm text-muted-foreground">Overview of your agent's status and capabilities.</p>
</div>
<div className="h-full flex flex-col">
<div className="container shrink-0 px-6 pt-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Dashboard</h1>
<p className="text-sm text-muted-foreground">Overview of your agent's status.</p>
</div>
{/* Row 1: Status + Chat (Left) | Agent Settings (Right) */}
<div className="flex gap-8 mb-6">
{/* Left: Status + Chat */}
<div className="flex-1">
{/* Status */}
{/* Status Section */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-1 mt-4">
<span className="relative flex size-2">
{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'}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
{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.'}
</p>
<Button
variant="outline"
size="lg"
className="gap-2"
onClick={() => navigate('/chat')}
disabled={!agentReady}
>
Start Chat
<ArrowRight className="size-4" />
</Button>
</div>
{/* Vertical Divider */}
<div className="w-px bg-border" />
{/* Right: Agent Settings (stacked vertically) */}
<div className="flex-1 space-y-4">
{/* Agent Profile */}
<div>
<span className="text-sm text-muted-foreground block mb-2">Agent Profile</span>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="w-full justify-between"
onClick={() => setSettingsOpen(true)}
size="lg"
className="gap-2"
onClick={() => navigate('/chat')}
disabled={!agentReady}
>
<span>{agentName || 'Unnamed Agent'}</span>
<Pencil className="size-4 text-muted-foreground" />
</Button>
</div>
{/* LLM Provider */}
<div className="relative" ref={dropdownRef}>
<span className="text-sm text-muted-foreground block mb-2">LLM Provider</span>
<Button
variant="outline"
className="w-full justify-between"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={providerLoading || switching}
>
<span className="flex items-center gap-2">
{current?.available ? (
<Check className="size-4 text-green-500" />
) : (
<AlertCircle className="size-4 text-yellow-500" />
)}
<span>{current?.providerName ?? 'Loading...'}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-xs font-mono">{current?.model ?? '-'}</span>
</span>
<ChevronDown
className={cn(
'size-4 text-muted-foreground transition-transform',
providerDropdownOpen && 'rotate-180'
)}
/>
Start Chat
<ArrowRight className="size-4" />
</Button>
{/* Provider Dropdown */}
{providerDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-2 z-10 bg-popover border border-border rounded-lg shadow-lg p-2 max-h-[50vh] overflow-y-auto">
<div className="grid grid-cols-3 gap-1.5">
{providers.map((p) => (
<button
key={p.id}
className={cn(
'flex items-center gap-1.5 px-2 py-1.5 rounded text-left text-xs transition-colors',
p.id === current?.provider
? 'bg-primary/10 border border-primary/30'
: 'hover:bg-accent/50 border border-transparent',
!p.available && 'opacity-60 hover:opacity-80'
)}
onClick={async () => {
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}
>
<span className={cn(
'size-1.5 rounded-full flex-shrink-0',
p.available ? 'bg-green-500' : 'bg-muted-foreground/50'
)} />
<span className="truncate font-medium">
{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]}
</span>
</button>
))}
</div>
{/* Model List */}
{(() => {
const currentProvider = providers.find(p => p.id === current?.provider)
if (!currentProvider || currentProvider.models.length <= 1) return null
return (
<div className="border-t border-border mt-2 pt-2">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider px-1 mb-1">
Models
</p>
<div className="space-y-0.5">
{currentProvider.models.map((model) => (
<button
key={model}
className={cn(
'w-full flex items-center gap-2 px-2 py-1 rounded text-left text-xs transition-colors',
model === current?.model
? 'bg-primary/10 text-foreground'
: 'hover:bg-accent/50 text-muted-foreground'
)}
onClick={async () => {
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}
>
<span className={cn(
'size-1.5 rounded-full flex-shrink-0',
model === current?.model ? 'bg-primary' : 'bg-muted-foreground/30'
)} />
<span className="font-mono truncate">{model}</span>
</button>
))}
</div>
</div>
)
})()}
</div>
{!agentReady && (
<Button
variant="ghost"
size="lg"
className="gap-2"
onClick={() => navigate('/agent/profile')}
>
<Settings className="size-4" />
Configure Agent
</Button>
)}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-border mb-6" />
{/* Section 3: Capabilities (Collapsible) */}
<Collapsible open={capabilitiesOpen} onOpenChange={setCapabilitiesOpen} className="mb-6">
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm">
<Brain className="size-4 text-muted-foreground" />
Your agent currently has {capabilitySummary}
<Tooltip>
<TooltipTrigger
onClick={refreshCapabilities}
disabled={capabilitiesRefreshing}
className="p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{capabilitiesRefreshing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</TooltipTrigger>
<TooltipContent>Refresh status</TooltipContent>
</Tooltip>
</span>
<CollapsibleTrigger className="group flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
{capabilitiesOpen ? 'Hide' : 'Details'}
<ChevronDown
className={cn(
'size-4 transition-transform',
capabilitiesOpen && 'rotate-180'
)}
/>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="pt-4 space-y-4">
{/* Skills */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Plug className="size-4 text-muted-foreground" />
<span>Skills ({skillStats.enabled})</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => navigate('/skills')}
>
View all
<ArrowRight className="size-3" />
</Button>
</div>
{skillStats.enabled > 0 ? (
<div className="flex flex-wrap gap-1.5">
{skills.filter(s => s.enabled).slice(0, 8).map((skill) => (
<span
key={skill.id}
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
title={skill.name}
>
{skill.name}
</span>
))}
{skillStats.enabled > 8 && (
<span className="text-xs px-2 py-1 text-muted-foreground">
+{skillStats.enabled - 8} more
</span>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">No skills enabled</p>
)}
</div>
{/* Tools */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Code className="size-4 text-muted-foreground" />
<span>Tools ({enabledTools})</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => navigate('/tools')}
>
View all
<ArrowRight className="size-3" />
</Button>
</div>
{enabledTools > 0 ? (
<div className="flex flex-wrap gap-1.5">
{tools.filter(t => t.enabled).slice(0, 8).map((tool) => (
<span
key={tool.name}
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
title={tool.description || tool.name}
>
{tool.name}
</span>
))}
{enabledTools > 8 && (
<span className="text-xs px-2 py-1 text-muted-foreground">
+{enabledTools - 8} more
</span>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">No tools enabled</p>
)}
</div>
{/* Channels */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Share className="size-4 text-muted-foreground" />
<span>Channels ({connectedChannels})</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => navigate('/channels')}
>
View all
<ArrowRight className="size-3" />
</Button>
</div>
{connectedChannels > 0 ? (
<div className="flex flex-wrap gap-1.5">
{channelStates.filter(s => s.status === 'running').slice(0, 8).map((channel) => (
<span
key={`${channel.channelId}-${channel.accountId}`}
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
>
{channel.channelId}/{channel.accountId}
</span>
))}
{connectedChannels > 8 && (
<span className="text-xs px-2 py-1 text-muted-foreground">
+{connectedChannels - 8} more
</span>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">No channels connected</p>
)}
</div>
{/* Cron Jobs */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
<span>Scheduled Tasks ({cronCount})</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => navigate('/crons')}
>
View all
<ArrowRight className="size-3" />
</Button>
</div>
{cronCount > 0 ? (
<div className="flex flex-wrap gap-1.5">
{cronJobs.slice(0, 8).map((job) => (
<span
key={job.id}
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
title={job.description || job.name}
>
{job.name}
</span>
))}
{cronCount > 8 && (
<span className="text-xs px-2 py-1 text-muted-foreground">
+{cronCount - 8} more
</span>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">No scheduled tasks</p>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* Divider */}
<div className="border-t border-border mb-6" />
{/* Section 4: Multi-Device Access */}
<div className="flex-1 min-h-0">
<div className="flex gap-8 h-full">
{/* Left: Connect */}
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Control from Anywhere</h3>
<Button
variant="outline"
size="sm"
onClick={() => setQrCodeExpanded(!qrCodeExpanded)}
>
<QrCode className="size-4 mr-1.5" />
{qrCodeExpanded ? 'Hide' : 'Show'}
</Button>
</div>
<p className="text-sm text-muted-foreground pl-0.5">
Open Multica Web on your phone and scan. Operate your computer and use your agent remotely.
</p>
{/* QR Code Container */}
<div className="flex-1 flex items-center justify-center mt-4">
{qrCodeExpanded ? (
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
expirySeconds={30}
size={140}
/>
) : (
<button
onClick={() => setQrCodeExpanded(true)}
className="flex flex-col items-center justify-center gap-3 p-8 rounded-xl border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 transition-colors cursor-pointer"
>
<QrCode className="size-12 text-muted-foreground/40" />
<span className="text-sm text-muted-foreground">Click to show QR code</span>
</button>
)}
</div>
</div>
{/* Vertical Divider */}
<div className="w-px bg-border self-stretch" />
{/* Right: Authorized Devices */}
<div className="flex-1 flex flex-col min-h-0">
<h3 className="text-sm font-medium mb-2">Authorized Devices</h3>
<p className="text-sm text-muted-foreground mb-4">
Devices you've approved to access your agent.
</p>
<div className="flex-1 overflow-auto">
<DeviceList />
</div>
</div>
</div>
</div>
{/* Dialogs */}
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
</div>
</div>
)
}

View file

@ -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 (
<header className="h-12 shrink-0 flex items-center px-4">
{/* Drag placeholder for traffic lights when sidebar is collapsed */}
<header
className="h-12 shrink-0 flex items-center px-4"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div
className={cn(
'h-full shrink-0 transition-[width] duration-200 ease-linear',
needsTrafficLightSpace ? 'w-16' : 'w-0'
)}
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<SidebarTrigger />
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<SidebarTrigger
className={cn(needsTrafficLightSpace && 'text-muted-foreground')}
/>
</div>
{/* Center: Current page */}
<div className="flex-1 flex justify-center">
{currentPage && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@ -114,95 +130,120 @@ function MainHeader() {
)}
</div>
{/* Right: Theme toggle */}
<ModeToggle />
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<ModeToggle />
</div>
</header>
)
}
export default function Layout() {
const location = useLocation()
const isAgentActive = location.pathname.startsWith('/agent')
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<SidebarProvider className="flex-1 overflow-hidden">
<Sidebar>
{/* Traffic light area with navigation */}
<SidebarHeader
className="h-12 shrink-0 flex items-center"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<NavigationButtons />
</SidebarHeader>
<SidebarHeader
className="h-12 shrink-0 flex items-center"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<NavigationButtons />
</SidebarHeader>
<SidebarContent>
{/* Brand */}
<div className="flex items-center gap-2 px-3 py-2">
<MulticaIcon bordered noSpin />
<span className="text-sm font-brand">Multica</span>
</div>
<SidebarContent>
<div className="flex items-center gap-2 px-3 py-2">
<MulticaIcon bordered noSpin />
<span className="text-sm font-brand">Multica</span>
</div>
{/* Main navigation */}
<SidebarGroup>
<SidebarMenu className="space-y-0.5">
{mainNavItems.map((item) => {
const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<item.icon
className={cn(
'size-4 transition-colors',
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
<SidebarGroup>
<SidebarMenu className="space-y-0.5">
{/* Main nav items */}
{mainNavItems.map((item) => {
const isActive = item.exact
? location.pathname === item.path
: location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<item.icon
className={cn(
'size-4 transition-colors',
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
)
})}
{/* Agent collapsible */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger
render={<SidebarMenuButton isActive={isAgentActive} />}
>
<Bot
className={cn(
'size-4 transition-colors',
!isAgentActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>Agent</span>
<ChevronDown className="ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{agentSubItems.map((item) => (
<SidebarMenuSubItem key={item.path}>
<SidebarMenuSubButton
render={<NavLink to={item.path} />}
isActive={location.pathname === item.path}
>
{item.label}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
</Collapsible>
{/* Configuration */}
<SidebarGroup>
<SidebarGroupLabel>Agent Configuration</SidebarGroupLabel>
<SidebarMenu className="space-y-0.5">
{configNavItems.map((item) => {
const isActive = location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<item.icon
className={cn(
'size-4 transition-colors',
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
{/* Bottom nav items */}
{bottomNavItems.map((item) => {
const isActive = location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<item.icon
className={cn(
'size-4 transition-colors',
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
)}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset className="overflow-hidden">
<MainHeader />
{/* Main Content */}
<main className="flex-1 overflow-hidden min-h-1">
<Outlet />
</main>
</SidebarInset>
<SidebarInset className="overflow-hidden">
<MainHeader />
<main className="flex-1 overflow-hidden min-h-1">
<Outlet />
</main>
</SidebarInset>
<DeviceConfirmDialog />
<UpdateNotification />

View file

@ -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 (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Skills</h1>
<p className="text-sm text-muted-foreground">
Skills are modular capabilities that expand what your agent can do. You can also ask your agent to create new skills for you.
</p>
</div>
{/* Configuration Area */}
<div className="flex-1 min-h-0">
<SkillList
skills={skills}
loading={loading}
error={error}
onToggleSkill={toggleSkill}
onRefresh={refresh}
/>
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="h-full overflow-auto">
<div className="container flex flex-col p-6">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Tools</h1>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
{/* Configuration Area */}
<div className="flex-1 min-h-0">
<ToolList
tools={tools}
loading={loading}
error={error}
onToggleTool={toggleTool}
onRefresh={refresh}
/>
</div>
</div>
</div>
)
}

View file

@ -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 (
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased h-dvh flex flex-col">
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body
className={`${geistMono.variable} ${playfair.variable} font-sans antialiased h-dvh flex flex-col`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"

View file

@ -13,6 +13,8 @@
"mu": "pnpm --filter @multica/cli dev",
"dev": "pnpm --filter @multica/desktop dev",
"dev:desktop": "pnpm --filter @multica/desktop dev",
"dev:desktop:reset": "rm -rf ~/.super-multica && echo '✓ Deleted ~/.super-multica - Fresh install state restored'",
"dev:desktop:fresh": "pnpm dev:desktop:reset && pnpm dev:desktop",
"dev:desktop:onboarding": "pnpm --filter @multica/desktop dev:onboarding",
"dev:gateway": "pnpm --filter @multica/gateway dev",
"dev:web": "pnpm --filter @multica/web dev",

View file

@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"gap-2 group/tabs flex data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
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<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("text-sm flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -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

View file

@ -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 {