refactor(desktop): restructure pages and consolidate UI components

- Rename channels.tsx to clients.tsx
- Remove standalone skills.tsx and tools.tsx pages
- Add agent/ directory for agent-related pages
- Update layout and navigation structure
- Add tabs component to ui package
- Update fonts and global styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-12 15:49:52 +08:00
parent ee8e0a67c9
commit bc9b7d6fc5
20 changed files with 1051 additions and 1038 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

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

@ -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,358 @@
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 [saving, setSaving] = useState(false)
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 () => {
setSaving(true)
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')
} finally {
setSaving(false)
}
}, [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 } 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,133 @@ 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>
)
}
function MulticaAppTab() {
const { hubInfo, agents } = useHubStore()
const primaryAgent = selectPrimaryAgent(agents)
const [qrCodeExpanded, setQrCodeExpanded] = useState(false)
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="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* QR Code Section */}
<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={() => setQrCodeExpanded(!qrCodeExpanded)}
>
<QrCode className="size-4 mr-1.5" />
{qrCodeExpanded ? 'Hide' : 'Show'}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-4">
{qrCodeExpanded ? (
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
expirySeconds={30}
size={160}
/>
) : (
<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>
</CardContent>
</Card>
{/* Device List Section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Authorized Devices</CardTitle>
<CardDescription>
Devices you've approved to access your agent.
</CardDescription>
</CardHeader>
<CardContent>
<DeviceList />
</CardContent>
</Card>
</div>
</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>
{/* 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

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