merge: resolve conflict with main (lucide-react migration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-12 14:47:00 +08:00
commit ece00cac2b
115 changed files with 5211 additions and 3290 deletions

View file

@ -122,6 +122,46 @@ See `docs/package-management.md` for detailed package management guide.
- **Comments**: Always write code comments in English, regardless of the conversation language.
## Design System
The UI follows a **restrained, professional** design language. This is a work tool, not a consumer app.
### Core Principles
1. **Restraint over decoration** — No flashy colors, minimal animations
2. **Clarity over cleverness** — Obvious > subtle, explicit > implicit
3. **Consistency over novelty** — Use Shadcn/UI patterns, don't reinvent
4. **Density over sprawl** — Respect screen real estate
### Typography
| Font | CSS Variable | Usage |
|------|--------------|-------|
| Geist Sans | `font-sans` | Primary UI text |
| Geist Mono | `font-mono` | Code, technical values |
| Playfair Display | `font-brand` | Brand name "Multica" ONLY |
Fonts are loaded via `@fontsource` packages (not Google Fonts) for cross-platform consistency.
### Colors
- **No brand color** — Purple/blue "AI colors" feel generic. We use neutral grays.
- **Color is for state** — Running (blue), success (green), error (red)
- **Dark mode is true dark** — Not gray, actual near-black
### Component Library
- **Base**: Shadcn/UI (Radix primitives + Tailwind)
- **Styling**: Tailwind CSS v4 with OKLCH colors
- **Config**: `packages/ui/src/styles/globals.css`
### When Building UI
- Prefer existing Shadcn components over custom implementations
- Use semantic color variables (`--muted`, `--destructive`), not raw colors
- Keep animations subtle and purposeful (no gratuitous motion)
- Test in both light and dark modes
## Credentials Setup
```bash

View file

@ -8,19 +8,19 @@
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"dev:onboarding": "electron-vite dev -- --force-onboarding",
"build": "electron-vite build && electron-builder",
"preview": "electron-vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@hugeicons/core-free-icons": "catalog:",
"@hugeicons/react": "catalog:",
"@multica/core": "workspace:*",
"@multica/hooks": "workspace:*",
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
"electron-updater": "^6.7.3",
"electron-updater": "^6.7.3",
"lucide-react": "^0.563.0",
"qrcode.react": "^4.2.0",
"react": "catalog:",
"react-dom": "catalog:",

View file

@ -142,6 +142,10 @@ interface ElectronAPI {
app: {
getFlags: () => Promise<{ forceOnboarding: boolean }>
}
appState: {
getOnboardingCompleted: () => Promise<boolean>
setOnboardingCompleted: (completed: boolean) => Promise<void>
}
hub: {
init: () => Promise<unknown>
getStatus: () => Promise<HubStatus>

View file

@ -48,6 +48,7 @@ import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js'
import { appStateManager } from '@multica/core'
import { createUpdater, AutoUpdater } from './updater/index.js'
// CJS output will have __dirname natively, but TypeScript source needs this for type checking
@ -74,8 +75,10 @@ function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 500,
minHeight: 520,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 12 },
trafficLightPosition: { x: 16, y: 17 }, // Vertically centered in 48px header
webPreferences: {
preload: path.join(__dirname, '../preload/index.cjs'),
// Enable node integration for IPC
@ -115,6 +118,13 @@ app.on('before-quit', () => {
})
app.whenReady().then(async () => {
// Reset onboarding if --force-onboarding flag is passed (for development testing)
if (forceOnboarding) {
console.log('[dev] Resetting onboarding state...')
appStateManager.resetOnboarding()
console.log('[dev] Onboarding state reset')
}
// App-level IPC handlers
ipcMain.handle('app:getFlags', () => ({ forceOnboarding }))

View file

@ -0,0 +1,31 @@
/**
* App State IPC handlers for Electron main process.
*
* Manages application-level state like onboarding status.
* State is persisted to ~/.super-multica/app-state.json
*/
import { ipcMain } from 'electron'
import { appStateManager } from '@multica/core'
/**
* Register all App State IPC handlers.
*/
export function registerAppStateIpcHandlers(): void {
/**
* Get onboarding completed status.
*/
ipcMain.handle('appState:getOnboardingCompleted', async (): Promise<boolean> => {
return appStateManager.getOnboardingCompleted()
})
/**
* Set onboarding completed status.
*/
ipcMain.handle(
'appState:setOnboardingCompleted',
async (_event, completed: boolean): Promise<void> => {
appStateManager.setOnboardingCompleted(completed)
console.log(`[IPC] Onboarding completed set to: ${completed}`)
}
)
}

View file

@ -9,6 +9,7 @@ export { registerProviderIpcHandlers } from './provider.js'
export { registerChannelsIpcHandlers } from './channels.js'
export { registerCronIpcHandlers } from './cron.js'
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
export { registerAppStateIpcHandlers } from './app-state.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
import { registerSkillsIpcHandlers } from './skills.js'
@ -18,6 +19,7 @@ import { registerProviderIpcHandlers } from './provider.js'
import { registerChannelsIpcHandlers } from './channels.js'
import { registerCronIpcHandlers } from './cron.js'
import { registerHeartbeatIpcHandlers } from './heartbeat.js'
import { registerAppStateIpcHandlers } from './app-state.js'
/**
* Register all IPC handlers.
@ -32,6 +34,7 @@ export function registerAllIpcHandlers(): void {
registerChannelsIpcHandlers()
registerCronIpcHandlers()
registerHeartbeatIpcHandlers()
registerAppStateIpcHandlers()
}
/**

View file

@ -103,6 +103,14 @@ const electronAPI = {
getFlags: (): Promise<{ forceOnboarding: boolean }> => ipcRenderer.invoke('app:getFlags'),
},
// App state (persisted to file system)
appState: {
/** Get onboarding completed status */
getOnboardingCompleted: (): Promise<boolean> => ipcRenderer.invoke('appState:getOnboardingCompleted'),
/** Set onboarding completed status */
setOnboardingCompleted: (completed: boolean): Promise<void> => ipcRenderer.invoke('appState:setOnboardingCompleted', completed),
},
// Hub management
hub: {
init: () => ipcRenderer.invoke('hub:init'),

View file

@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multica</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root {
--font-brand: "Playfair Display", serif;
}
</style>
</head>
<body>
<div id="root"></div>

View file

@ -1,5 +1,8 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './components/theme-provider'
import { TooltipProvider } from '@multica/ui/components/ui/tooltip'
import { Toaster } from './components/toaster'
import Layout from './pages/layout'
import HomePage from './pages/home'
import ChatPage from './pages/chat'
@ -7,30 +10,25 @@ import ToolsPage from './pages/tools'
import SkillsPage from './pages/skills'
import ChannelsPage from './pages/channels'
import CronsPage from './pages/crons'
import OnboardingLayout from './pages/onboarding/layout'
import PermissionsStep from './pages/onboarding/permissions'
import SetupStep from './pages/onboarding/setup'
import TryItStep from './pages/onboarding/try-it'
import ConnectStep from './pages/onboarding/connect'
import OnboardingPage from './pages/onboarding'
import { useOnboardingStore } from './stores/onboarding'
import { useHubStore } from './stores/hub'
import { useProviderStore } from './stores/provider'
import { useChannelsStore } from './stores/channels'
import { useSkillsStore } from './stores/skills'
import { useToolsStore } from './stores/tools'
import { useCronJobsStore } from './stores/cron-jobs'
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const completed = useOnboardingStore((s) => s.completed)
const forceOnboarding = useOnboardingStore((s) => s.forceOnboarding)
if (!completed || forceOnboarding) return <Navigate to="/onboarding" replace />
if (!completed) return <Navigate to="/onboarding" replace />
return <>{children}</>
}
const router = createHashRouter([
{
path: '/onboarding',
element: <OnboardingLayout />,
children: [
{ index: true, element: <PermissionsStep /> },
{ path: 'setup', element: <SetupStep /> },
{ path: 'connect', element: <ConnectStep /> },
{ path: 'try-it', element: <TryItStep /> },
],
element: <OnboardingPage />,
},
{
path: '/',
@ -54,9 +52,50 @@ const router = createHashRouter([
])
export default function App() {
useEffect(() => {
useOnboardingStore.getState().initForceFlag()
}, [])
const [isHydrated, setIsHydrated] = useState(false)
const setCompleted = useOnboardingStore((s) => s.setCompleted)
return <RouterProvider router={router} />
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)
}
}
hydrateOnboardingState()
// Initialize hub and prefetch global data at app startup
useHubStore.getState().init()
useProviderStore.getState().fetch()
useChannelsStore.getState().fetch()
useSkillsStore.getState().fetch()
useToolsStore.getState().fetch()
useCronJobsStore.getState().fetch()
}, [setCompleted])
// Show nothing while loading onboarding state to prevent flash
if (!isHydrated) {
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<div className="h-dvh bg-background" />
</ThemeProvider>
)
}
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<TooltipProvider>
<RouterProvider router={router} />
<Toaster position="bottom-right" />
</TooltipProvider>
</ThemeProvider>
)
}

View file

@ -11,8 +11,7 @@ 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 { Label } from '@multica/ui/components/ui/label'
import { HugeiconsIcon } from '@hugeicons/react'
import { Loading03Icon } from '@hugeicons/core-free-icons'
import { Loader2 } from 'lucide-react'
interface AgentSettingsDialogProps {
open: boolean
@ -72,7 +71,7 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP
{loading ? (
<div className="flex items-center justify-center py-8">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
@ -109,7 +108,7 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving && <HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />}
{saving && <Loader2 className="size-4 animate-spin mr-2" />}
Save
</Button>
</DialogFooter>

View file

@ -10,8 +10,7 @@ import {
import { Button } from '@multica/ui/components/ui/button'
import { Input } from '@multica/ui/components/ui/input'
import { Label } from '@multica/ui/components/ui/label'
import { HugeiconsIcon } from '@hugeicons/react'
import { Loading03Icon, Key01Icon, Tick02Icon } from '@hugeicons/core-free-icons'
import { Loader2, Key, Check } from 'lucide-react'
type Phase = 'input' | 'saving' | 'testing' | 'success' | 'error'
@ -111,7 +110,7 @@ export function ApiKeyDialog({
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HugeiconsIcon icon={Key01Icon} className="size-5" />
<Key className="size-5" />
Configure {providerName}
</DialogTitle>
<DialogDescription>
@ -153,21 +152,21 @@ export function ApiKeyDialog({
{/* Status messages */}
{phase === 'saving' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
Saving API key...
</div>
)}
{phase === 'testing' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
Testing connection...
</div>
)}
{phase === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
<Check className="size-4" />
Connected successfully!
</div>
)}

View file

@ -1,17 +1,16 @@
import { useState } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
RotateClockwiseIcon,
Delete02Icon,
Loading03Icon,
Time04Icon,
CheckmarkCircle02Icon,
CancelCircleIcon,
AlertCircleIcon,
} from '@hugeicons/core-free-icons'
import type { CronJobInfo } from '../hooks/use-cron-jobs'
RotateCw,
Trash2,
Loader2,
Clock,
CheckCircle,
XCircle,
AlertCircle,
} from 'lucide-react'
import type { CronJobInfo } from '../stores/cron-jobs'
interface CronJobListProps {
jobs: CronJobInfo[]
@ -32,14 +31,14 @@ function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
}
const config = {
ok: { icon: CheckmarkCircle02Icon, className: 'text-emerald-600', label: 'ok' },
error: { icon: CancelCircleIcon, className: 'text-destructive', label: 'error' },
skipped: { icon: AlertCircleIcon, className: 'text-yellow-600', label: 'skipped' },
ok: { Icon: CheckCircle, className: 'text-emerald-600', label: 'ok' },
error: { Icon: XCircle, className: 'text-destructive', label: 'error' },
skipped: { Icon: AlertCircle, className: 'text-yellow-600', label: 'skipped' },
}[status]
return (
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
<HugeiconsIcon icon={config.icon} className="size-3.5" />
<config.Icon className="size-3.5" />
{config.label}
</span>
)
@ -101,7 +100,7 @@ export function CronJobList({
if (loading && jobs.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
</div>
)
@ -121,10 +120,11 @@ export function CronJobList({
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
Refresh
</Button>
</div>
@ -139,7 +139,7 @@ export function CronJobList({
{/* Empty state */}
{jobs.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<HugeiconsIcon icon={Time04Icon} className="size-10 text-muted-foreground/50 mb-3" />
<Clock className="size-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Use the cron tool in Chat to create one.
@ -189,13 +189,13 @@ export function CronJobList({
disabled={isRemoving}
>
{isRemoving ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
) : (
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
<Trash2 className="size-4" />
)}
</Button>
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={job.enabled}

View file

@ -1,12 +1,11 @@
import { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
SmartPhone01Icon,
Delete02Icon,
Loading03Icon,
RotateClockwiseIcon,
} from '@hugeicons/core-free-icons'
Smartphone,
Trash2,
Loader2,
RotateCw,
} from 'lucide-react'
import { useDevices, type DeviceEntry } from '../hooks/use-devices'
import { parseUserAgent } from '../lib/parse-user-agent'
@ -62,7 +61,7 @@ function DeviceItem({
return (
<div className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-3 min-w-0 flex-1">
<HugeiconsIcon icon={SmartPhone01Icon} className="size-4 text-muted-foreground shrink-0" />
<Smartphone className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@ -80,9 +79,9 @@ function DeviceItem({
disabled={revoking}
>
{revoking ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
<Loader2 className="size-4 animate-spin" />
) : (
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
<Trash2 className="size-4" />
)}
</Button>
</div>
@ -90,14 +89,21 @@ function DeviceItem({
}
export function DeviceList() {
const { devices, loading, refresh, revokeDevice } = useDevices()
const { devices, loading, refreshing, refresh, revokeDevice } = useDevices()
if (loading) {
return null
}
if (devices.length === 0) {
return null
return (
<div className="h-full flex flex-col items-center justify-center text-center">
<Smartphone className="size-8 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No devices connected yet.
</p>
</div>
)
}
return (
@ -111,8 +117,13 @@ export function DeviceList() {
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={refresh}
disabled={refreshing}
>
<HugeiconsIcon icon={RotateClockwiseIcon} className="size-3" />
{refreshing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RotateCw className="size-3" />
)}
Refresh
</Button>
</div>

View file

@ -1,12 +1,18 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading } from '@multica/ui/components/ui/loading'
import { ChatView } from '@multica/ui/components/chat-view'
import { useLocalChat } from '../hooks/use-local-chat'
import { useProvider } from '../hooks/use-provider'
import { useProviderStore } from '../stores/provider'
import { ApiKeyDialog } from './api-key-dialog'
import { OAuthDialog } from './oauth-dialog'
export function LocalChat() {
interface LocalChatProps {
initialPrompt?: string
}
export function LocalChat({ initialPrompt }: LocalChatProps) {
const navigate = useNavigate()
const {
agentId,
initError,
@ -25,7 +31,7 @@ export function LocalChat() {
clearError,
} = useLocalChat()
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProvider()
const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore()
// Provider config dialog state
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
@ -57,6 +63,21 @@ export function LocalChat() {
// Derive provider info for dialogs
const currentMeta = current ? providers.find((p) => p.id === current.provider) : null
// Auto-send initial prompt after a short delay
const hasSentInitialPrompt = useRef(false)
useEffect(() => {
if (!agentId || !initialPrompt || hasSentInitialPrompt.current) return
const timer = setTimeout(() => {
hasSentInitialPrompt.current = true
sendMessage(initialPrompt)
// Remove prompt from URL to prevent re-sending on back navigation
navigate('/chat', { replace: true })
}, 500)
return () => clearTimeout(timer)
}, [agentId, initialPrompt, sendMessage, navigate])
if (initError) {
return (
<div className="flex-1 flex items-center justify-center text-sm text-destructive">

View file

@ -0,0 +1,37 @@
import { Sun, Moon, Monitor } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu"
import { useTheme } from "./theme-provider"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor
return (
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center size-8 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Icon className="size-4" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="size-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="size-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="size-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -8,8 +8,7 @@ import {
DialogDescription,
} from '@multica/ui/components/ui/dialog'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { Loading03Icon, CommandLineIcon, RefreshIcon, Tick02Icon } from '@hugeicons/core-free-icons'
import { Loader2, Terminal, RefreshCw, Check } from 'lucide-react'
interface OAuthDialogProps {
open: boolean
@ -82,7 +81,7 @@ export function OAuthDialog({
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HugeiconsIcon icon={CommandLineIcon} className="size-5" />
<Terminal className="size-5" />
Configure {providerName}
</DialogTitle>
<DialogDescription>
@ -116,7 +115,7 @@ export function OAuthDialog({
{success && (
<div className="bg-green-500/10 text-green-600 dark:text-green-400 rounded-md p-3 text-sm flex items-center gap-2">
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
<Check className="size-4" />
<span>
Credentials imported successfully!
{expiresAt && ` (expires in ${formatExpiry(expiresAt)})`}
@ -131,9 +130,9 @@ export function OAuthDialog({
</Button>
<Button onClick={handleImport} disabled={importing || success}>
{importing ? (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin mr-2" />
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<HugeiconsIcon icon={RefreshIcon} className="size-4 mr-2" />
<RefreshCw className="size-4 mr-2" />
)}
Refresh
</Button>

View file

@ -1,9 +1,8 @@
import { Switch } from '@multica/ui/components/ui/switch'
import { HugeiconsIcon } from '@hugeicons/react'
import type { IconSvgElement } from '@hugeicons/react'
import type { LucideIcon } from 'lucide-react'
interface AcknowledgementItemProps {
icon: IconSvgElement
icon: LucideIcon
title: string
description: string
checked: boolean
@ -11,7 +10,7 @@ interface AcknowledgementItemProps {
}
export function AcknowledgementItem({
icon,
icon: Icon,
title,
description,
checked,
@ -20,7 +19,7 @@ export function AcknowledgementItem({
return (
<label className="flex items-start gap-4 p-4 rounded-xl border border-border bg-card cursor-pointer hover:bg-accent/30 transition-colors">
<div className="mt-0.5 flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<HugeiconsIcon icon={icon} className="size-4 text-muted-foreground" />
<Icon className="size-4 text-muted-foreground" />
</div>
<div className="flex-1 space-y-0.5">
<p className="font-medium text-sm">{title}</p>

View file

@ -1,25 +1,20 @@
import { HugeiconsIcon } from '@hugeicons/react'
import {
Key01Icon,
Database01Icon,
CommandLineIcon,
} from '@hugeicons/core-free-icons'
import { Key, Database, Terminal } from 'lucide-react'
const privacyItems = [
{
icon: Database01Icon,
icon: Database,
title: 'Everything stays local',
description:
'All sessions, history, and profiles are stored on your device. Nothing leaves your computer.',
},
{
icon: Key01Icon,
icon: Key,
title: 'Your data, your control',
description:
'API keys and credentials are saved locally in ~/.super-multica/. We never access them.',
},
{
icon: CommandLineIcon,
icon: Terminal,
title: 'Transparent execution',
description:
'Every shell command the agent wants to run requires your explicit approval first.',
@ -32,7 +27,7 @@ export function PrivacyPanel() {
{privacyItems.map((item) => (
<div key={item.title} className="flex gap-3">
<div className="mt-0.5 flex items-center justify-center size-7 rounded-lg bg-primary/10 shrink-0">
<HugeiconsIcon icon={item.icon} className="size-4 text-primary" />
<item.icon className="size-4 text-primary" />
</div>
<div className="space-y-0.5">
<p className="font-medium text-sm text-primary">{item.title}</p>

View file

@ -1,6 +1,5 @@
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { Tick02Icon } from '@hugeicons/core-free-icons'
import { Check } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
interface ProviderSetupProps {
@ -64,7 +63,7 @@ function ProviderCard({
{provider.available ? (
<div className="flex items-center gap-2">
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-primary" />
<Check className="size-4 text-primary" />
<Button
size="sm"
variant="ghost"

View file

@ -1,5 +1,4 @@
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowRight01Icon } from '@hugeicons/core-free-icons'
import { ArrowRight } from 'lucide-react'
interface SamplePromptProps {
title: string
@ -17,10 +16,7 @@ export function SamplePrompt({ title, prompt, onClick }: SamplePromptProps) {
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground truncate">{prompt}</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0"
/>
<ArrowRight className="size-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
</button>
)
}

View file

@ -1,27 +1,19 @@
import { cn } from '@multica/ui/lib/utils'
import { HugeiconsIcon } from '@hugeicons/react'
import { Tick02Icon } from '@hugeicons/core-free-icons'
import { Check } from 'lucide-react'
export type StepId = 'permissions' | 'setup' | 'connect' | 'try-it'
interface Step {
id: StepId
label: string
}
const steps: Step[] = [
{ id: 'permissions', label: 'Permissions' },
{ id: 'setup', label: 'Provider' },
{ id: 'connect', label: 'Connect' },
{ id: 'try-it', label: 'Try it' },
const steps = [
{ label: 'Privacy' },
{ label: 'Provider' },
{ label: 'Connect' },
{ label: 'Try it' },
]
interface StepperProps {
currentStep: StepId
currentStep: number // 1-based index
}
export function Stepper({ currentStep }: StepperProps) {
const currentIndex = steps.findIndex((s) => s.id === currentStep)
const currentIndex = currentStep - 1 // Convert to 0-based
// Progress: 0% at step 0, 50% at step 1, 100% at step 2
const progress = (currentIndex / (steps.length - 1)) * 100
@ -31,10 +23,10 @@ export function Stepper({ currentStep }: StepperProps) {
<nav className="flex items-center justify-center gap-3">
{steps.map((step, index) => {
const isCompleted = index < currentIndex
const isCurrent = step.id === currentStep
const isCurrent = index === currentIndex
return (
<div key={step.id} className="flex items-center gap-3">
<div key={step.label} className="flex items-center gap-3">
{index > 0 && (
<span
className={cn(
@ -56,10 +48,7 @@ export function Stepper({ currentStep }: StepperProps) {
)}
>
{isCompleted && (
<HugeiconsIcon
icon={Tick02Icon}
className="size-3.5 text-foreground"
/>
<Check className="size-3.5 text-foreground" />
)}
{step.label}
</span>

View file

@ -1,12 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
RefreshIcon,
CheckmarkCircle02Icon,
Copy01Icon,
} from '@hugeicons/core-free-icons'
import { RefreshCw, CheckCircle, Copy } from 'lucide-react'
export interface QRCodeData {
type: 'multica-connect'
@ -177,7 +172,7 @@ export function ConnectionQRCode({
{isExpired && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm rounded-xl flex items-center justify-center">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<HugeiconsIcon icon={RefreshIcon} className="size-4 mr-2" />
<RefreshCw className="size-4 mr-2" />
Refresh
</Button>
</div>
@ -185,13 +180,9 @@ export function ConnectionQRCode({
</div>
{/* Info section */}
<div className="mt-6 text-center space-y-3">
<p className="text-sm text-muted-foreground">
Scan with your phone to connect
</p>
<div className="mt-4 space-y-2">
{/* Expiry timer */}
<div className="flex items-center gap-3 justify-center">
<div className="flex items-center gap-2">
<span
className={`text-xs font-mono ${
isExpiringSoon
@ -203,32 +194,21 @@ export function ConnectionQRCode({
>
{isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`}
</span>
{!isExpired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleRefresh}
>
<HugeiconsIcon icon={RefreshIcon} className="size-3" />
Refresh
</Button>
)}
<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>
{/* Copy link button */}
<Button
variant="outline"
size="sm"
className="text-xs gap-1.5"
onClick={handleCopyLink}
>
<HugeiconsIcon
icon={copied ? CheckmarkCircle02Icon : Copy01Icon}
className="size-3.5"
/>
{copied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</div>
)

View file

@ -1,51 +0,0 @@
import { Loading } from '@multica/ui/components/ui/loading'
import { ChatView } from '@multica/ui/components/chat-view'
import { DevicePairing } from '@multica/ui/components/device-pairing'
import { useGatewayChat } from '@multica/hooks/use-gateway-chat'
import type { UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection'
export function RemoteChat({ gateway }: { gateway: UseGatewayConnectionReturn }) {
const { pageState, connectionState, error, client, identity, pairingKey, connect, disconnect } = gateway
return (
<div className="h-full flex flex-col overflow-hidden w-full">
{pageState === 'loading' && (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />
Loading...
</div>
)}
{(pageState === 'not-connected' || pageState === 'connecting') && (
<DevicePairing
key={pairingKey}
connectionState={connectionState}
lastError={error}
onConnect={connect}
onCancel={disconnect}
/>
)}
{pageState === 'connected' && client && identity && (
<ConnectedChat
client={client}
hubId={identity.hubId}
agentId={identity.agentId}
/>
)}
</div>
)
}
function ConnectedChat({
client,
hubId,
agentId,
}: {
client: NonNullable<UseGatewayConnectionReturn['client']>
hubId: string
agentId: string
}) {
const chat = useGatewayChat({ client, hubId, agentId })
return <ChatView {...chat} />
}

View file

@ -2,14 +2,13 @@ import { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { Badge } from '@multica/ui/components/ui/badge'
import { Switch } from '@multica/ui/components/ui/switch'
import { HugeiconsIcon } from '@hugeicons/react'
import {
RotateClockwiseIcon,
Loading03Icon,
CheckmarkCircle02Icon,
Cancel01Icon,
} from '@hugeicons/core-free-icons'
import type { SkillInfo, SkillSource } from '../hooks/use-skills'
RotateCw,
Loader2,
CheckCircle,
X,
} from 'lucide-react'
import type { SkillInfo, SkillSource } from '../stores/skills'
// Source badge colors
const SOURCE_COLORS: Record<SkillSource, string> = {
@ -69,7 +68,7 @@ export function SkillList({
if (loading && skills.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading skills...</span>
</div>
)
@ -89,10 +88,11 @@ export function SkillList({
disabled={loading}
className="gap-1.5"
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
Refresh
</Button>
</div>
@ -175,10 +175,11 @@ export function SkillList({
: 'text-muted-foreground'
}`}
>
<HugeiconsIcon
icon={skill.enabled ? CheckmarkCircle02Icon : Cancel01Icon}
className="size-4"
/>
{skill.enabled ? (
<CheckCircle className="size-4" />
) : (
<X className="size-4" />
)}
<span className="text-xs font-medium">
{skill.enabled ? 'Enabled' : 'Disabled'}
</span>
@ -188,10 +189,7 @@ export function SkillList({
{/* Right: Toggle */}
<div className="flex items-center gap-2">
{isToggling && (
<HugeiconsIcon
icon={Loading03Icon}
className="size-4 animate-spin text-muted-foreground"
/>
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={skill.enabled}

View file

@ -0,0 +1,91 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
resolvedTheme: "light",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
function getSystemTheme(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
}
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "multica-theme",
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(
() => (theme === "system" ? getSystemTheme() : theme)
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
const resolved = theme === "system" ? getSystemTheme() : theme
root.classList.add(resolved)
setResolvedTheme(resolved)
}, [theme])
// Listen for system theme changes
useEffect(() => {
if (theme !== "system") return
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleChange = () => {
const resolved = getSystemTheme()
const root = window.document.documentElement
root.classList.remove("light", "dark")
root.classList.add(resolved)
setResolvedTheme(resolved)
}
mediaQuery.addEventListener("change", handleChange)
return () => mediaQuery.removeEventListener("change", handleChange)
}, [theme])
const value = {
theme,
resolvedTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View file

@ -0,0 +1,30 @@
import { Toaster as Sonner, type ToasterProps } from 'sonner'
import { CheckCircle, Info, AlertCircle, XCircle, Loader2 } from 'lucide-react'
import { useTheme } from './theme-provider'
export function Toaster(props: ToasterProps) {
const { resolvedTheme } = useTheme()
return (
<Sonner
theme={resolvedTheme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <CheckCircle className="size-4 text-emerald-500" />,
info: <Info className="size-4 text-blue-500" />,
warning: <AlertCircle className="size-4 text-amber-500" />,
error: <XCircle className="size-4 text-red-500" />,
loading: <Loader2 className="size-4 text-muted-foreground animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
)
}

View file

@ -1,35 +1,44 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Switch } from '@multica/ui/components/ui/switch'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
RotateClockwiseIcon,
FolderOpenIcon,
CodeIcon,
GlobalIcon,
AiBrainIcon,
ArrowDown01Icon,
ArrowUp01Icon,
Loading03Icon,
Time04Icon,
UserMultipleIcon,
} from '@hugeicons/core-free-icons'
import type { ToolInfo, ToolGroup } from '../hooks/use-tools'
RotateCw,
FolderOpen,
Code,
Globe,
Brain,
ChevronDown,
ChevronUp,
Loader2,
Clock,
Users,
} from 'lucide-react'
import type { ToolInfo } from '../stores/tools'
// Group display names
const GROUP_NAMES: Record<string, string> = {
fs: 'File System',
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
}
// Group icons
const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
fs: FolderOpenIcon,
runtime: CodeIcon,
web: GlobalIcon,
memory: AiBrainIcon,
subagent: UserMultipleIcon,
cron: Time04Icon,
other: CodeIcon,
const GROUP_ICONS: Record<string, typeof FolderOpen> = {
fs: FolderOpen,
runtime: Code,
web: Globe,
memory: Brain,
subagent: Users,
cron: Clock,
other: Code,
}
interface ToolListProps {
tools: ToolInfo[]
groups: ToolGroup[]
loading: boolean
error: string | null
onToggleTool: (toolName: string) => Promise<void>
@ -38,12 +47,23 @@ interface ToolListProps {
export function ToolList({
tools,
groups,
loading,
error,
onToggleTool,
onRefresh,
}: ToolListProps) {
// Compute groups from tools
const groups = useMemo(() => {
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,
}))
}, [tools])
// Track which groups are expanded
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(groups.map((g) => g.id))
@ -77,18 +97,10 @@ export function ToolList({
}
}
// Group tools by their group
const toolsByGroup = groups.map((group) => ({
...group,
tools: tools.filter((t) => t.group === group.id),
enabledCount: tools.filter((t) => t.group === group.id && t.enabled).length,
totalCount: tools.filter((t) => t.group === group.id).length,
}))
if (loading && tools.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
<Loader2 className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading tools...</span>
</div>
)
@ -109,10 +121,11 @@ export function ToolList({
className="gap-1.5"
disabled={loading}
>
<HugeiconsIcon
icon={loading ? Loading03Icon : RotateClockwiseIcon}
className={`size-4 ${loading ? 'animate-spin' : ''}`}
/>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
Refresh
</Button>
</div>
@ -126,9 +139,9 @@ export function ToolList({
{/* Tool groups */}
<div className="space-y-2">
{toolsByGroup.map((group) => {
{groups.map((group) => {
const isExpanded = expandedGroups.has(group.id)
const GroupIcon = GROUP_ICONS[group.id] || CodeIcon
const GroupIcon = GROUP_ICONS[group.id] || Code
return (
<div
@ -141,16 +154,17 @@ export function ToolList({
className="w-full flex items-center justify-between px-4 py-3 bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<HugeiconsIcon icon={GroupIcon} className="size-5 text-muted-foreground" />
<GroupIcon className="size-5 text-muted-foreground" />
<span className="font-medium">{group.name}</span>
<span className="text-xs text-muted-foreground">
{group.enabledCount}/{group.totalCount} enabled
</span>
</div>
<HugeiconsIcon
icon={isExpanded ? ArrowUp01Icon : ArrowDown01Icon}
className="size-4 text-muted-foreground"
/>
{isExpanded ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{/* Group tools */}
@ -183,7 +197,7 @@ export function ToolList({
</div>
<div className="flex items-center gap-2">
{isToggling && (
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<Switch
checked={tool.enabled}

View file

@ -3,14 +3,13 @@
* Shows when a new version is available and allows user to download/install
*/
import { useState, useEffect } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Download04Icon,
Loading03Icon,
CheckmarkCircle02Icon,
AlertCircleIcon,
Cancel01Icon,
} from '@hugeicons/core-free-icons'
Download,
Loader2,
CheckCircle,
AlertCircle,
X,
} from 'lucide-react'
import { Button } from '@multica/ui/components/ui/button'
interface UpdateInfo {
@ -77,13 +76,13 @@ export function UpdateNotification(): React.JSX.Element | null {
className={`flex h-8 w-8 items-center justify-center rounded-full ${isError ? 'bg-destructive/10' : 'bg-primary/10'}`}
>
{isError ? (
<HugeiconsIcon icon={AlertCircleIcon} className="h-4 w-4 text-destructive" />
<AlertCircle className="h-4 w-4 text-destructive" />
) : updateStatus.status === 'downloaded' ? (
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-4 w-4 text-primary" />
<CheckCircle className="h-4 w-4 text-primary" />
) : updateStatus.status === 'downloading' ? (
<HugeiconsIcon icon={Loading03Icon} className="h-4 w-4 text-primary animate-spin" />
<Loader2 className="h-4 w-4 text-primary animate-spin" />
) : (
<HugeiconsIcon icon={Download04Icon} className="h-4 w-4 text-primary" />
<Download className="h-4 w-4 text-primary" />
)}
</div>
@ -134,7 +133,7 @@ export function UpdateNotification(): React.JSX.Element | null {
)}
{updateStatus.status !== 'downloading' && (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleDismiss}>
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
<X className="h-4 w-4" />
</Button>
)}
</div>

View file

@ -1,135 +0,0 @@
/**
* Hook for managing channel accounts (Telegram, Discord, etc.) in the Desktop App.
*
* Provides state and actions for the Channels settings page:
* - List channel account states (running / stopped / error)
* - Read channel config (tokens)
* - Save / remove tokens with immediate start/stop
*/
import { useState, useEffect, useCallback } from 'react'
export interface UseChannelsReturn {
/** Runtime states of all channel accounts */
states: ChannelAccountStateInfo[]
/** Raw channel config from credentials.json5 */
config: Record<string, Record<string, Record<string, unknown>> | undefined>
/** Loading state */
loading: boolean
/** Error message if any */
error: string | null
/** Refresh states and config */
refresh: () => Promise<void>
/** Save a bot token — persists to file and starts the bot immediately */
saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }>
/** Remove a bot token — stops the bot and removes from file */
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
/** Stop a channel account without removing config */
stopChannel: (channelId: string, accountId: string) => Promise<void>
/** Start a channel account from saved config */
startChannel: (channelId: string, accountId: string) => Promise<void>
}
export function useChannels(): UseChannelsReturn {
const [states, setStates] = useState<ChannelAccountStateInfo[]>([])
const [config, setConfig] = useState<Record<string, Record<string, Record<string, unknown>> | undefined>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [stateList, channelConfig] = await Promise.all([
window.electronAPI.channels.listStates(),
window.electronAPI.channels.getConfig(),
])
setStates(stateList)
setConfig(channelConfig)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
console.error('[useChannels] Failed to load:', message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const saveToken = useCallback(async (channelId: string, accountId: string, token: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.saveToken(channelId, accountId, token)
if (!result.ok) {
setError(result.error ?? 'Failed to save token')
}
// Refresh to pick up new state
await refresh()
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
return { ok: false, error: message }
}
}, [refresh])
const removeToken = useCallback(async (channelId: string, accountId: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.removeToken(channelId, accountId)
if (!result.ok) {
setError(result.error ?? 'Failed to remove token')
}
await refresh()
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
return { ok: false, error: message }
}
}, [refresh])
const stopChannel = useCallback(async (channelId: string, accountId: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.stop(channelId, accountId)
if (!result.ok) {
setError(result.error ?? 'Failed to stop channel')
}
await refresh()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
}
}, [refresh])
const startChannel = useCallback(async (channelId: string, accountId: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.start(channelId, accountId)
if (!result.ok) {
setError(result.error ?? 'Failed to start channel')
}
await refresh()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
}
}, [refresh])
return {
states,
config,
loading,
error,
refresh,
saveToken,
removeToken,
stopChannel,
startChannel,
}
}

View file

@ -1,107 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
export interface CronJobInfo {
id: string
name: string
description?: string
enabled: boolean
schedule: string
sessionTarget: string
nextRunAt: string | null
lastStatus: 'ok' | 'error' | 'skipped' | null
lastRunAt: string | null
lastDurationMs: number | null
lastError: string | null
}
export interface UseCronJobsReturn {
jobs: CronJobInfo[]
loading: boolean
error: string | null
toggleJob: (jobId: string) => Promise<void>
removeJob: (jobId: string) => Promise<void>
refresh: () => Promise<void>
}
export function useCronJobs(): UseCronJobsReturn {
const [jobs, setJobs] = useState<CronJobInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchJobs = useCallback(async () => {
try {
setLoading(true)
setError(null)
const result = window.electronAPI
? await window.electronAPI.cron.list()
: await window.ipcRenderer.invoke('cron:list')
if (Array.isArray(result)) {
setJobs(result)
} else {
setError('Invalid response from cron:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs')
setJobs([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchJobs()
}, [fetchJobs])
const toggleJob = useCallback(async (jobId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.cron.toggle(jobId)
: await window.ipcRenderer.invoke('cron:toggle', jobId)
const typed = result as { error?: string; id?: string; enabled?: boolean }
if (typed.error) {
setError(typed.error)
return
}
setJobs((prev) =>
prev.map((job) =>
job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to toggle job')
}
}, [])
const removeJob = useCallback(async (jobId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.cron.remove(jobId)
: await window.ipcRenderer.invoke('cron:remove', jobId)
const typed = result as { error?: string; ok?: boolean }
if (typed.error) {
setError(typed.error)
return
}
setJobs((prev) => prev.filter((job) => job.id !== jobId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove job')
}
}, [])
return {
jobs,
loading,
error,
toggleJob,
removeJob,
refresh: fetchJobs,
}
}
export default useCronJobs

View file

@ -1,4 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 600
export interface DeviceMeta {
userAgent?: string
@ -17,6 +21,7 @@ export interface DeviceEntry {
export interface UseDevicesReturn {
devices: DeviceEntry[]
loading: boolean
refreshing: boolean
refresh: () => Promise<void>
revokeDevice: (deviceId: string) => Promise<boolean>
}
@ -24,8 +29,10 @@ export interface UseDevicesReturn {
export function useDevices(): UseDevicesReturn {
const [devices, setDevices] = useState<DeviceEntry[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(async () => {
// Initial fetch (silent, no toast)
const fetchDevices = useCallback(async () => {
try {
const list = await window.electronAPI?.hub.listDevices()
setDevices((list as DeviceEntry[]) ?? [])
@ -36,33 +43,62 @@ export function useDevices(): UseDevicesReturn {
}
}, [])
// Manual refresh (with feedback)
const refresh = useCallback(async () => {
setRefreshing(true)
const startTime = Date.now()
try {
const list = await window.electronAPI?.hub.listDevices()
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
setDevices((list as DeviceEntry[]) ?? [])
toast.success('Device list refreshed')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to refresh devices', { description: message })
console.error('Failed to refresh devices:', err)
} finally {
setRefreshing(false)
}
}, [])
const revokeDevice = useCallback(async (deviceId: string): Promise<boolean> => {
try {
const result = await window.electronAPI?.hub.revokeDevice(deviceId)
if (result?.ok) {
setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId))
toast.success('Device removed')
return true
}
toast.error('Failed to remove device')
return false
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to remove device', { description: message })
console.error('Failed to revoke device:', err)
return false
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
fetchDevices()
}, [fetchDevices])
// Subscribe to device list changes pushed from main process
// Subscribe to device list changes pushed from main process (silent refresh)
useEffect(() => {
window.electronAPI?.hub.onDevicesChanged(() => {
refresh()
fetchDevices()
})
return () => {
window.electronAPI?.hub.offDevicesChanged()
}
}, [refresh])
}, [fetchDevices])
return { devices, loading, refresh, revokeDevice }
return { devices, loading, refreshing, refresh, revokeDevice }
}

View file

@ -1,70 +0,0 @@
import { useCallback, useEffect, useState } from "react";
export type HeartbeatEvent = {
ts: number;
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
preview?: string;
durationMs?: number;
reason?: string;
};
export function useHeartbeat() {
const [enabled, setEnabled] = useState(true);
const [lastEvent, setLastEvent] = useState<HeartbeatEvent | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
setError(null);
const event = (await window.electronAPI.heartbeat.last()) as HeartbeatEvent | null;
setLastEvent(event);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15000);
return () => clearInterval(timer);
}, [refresh]);
const toggleEnabled = useCallback(async () => {
const next = !enabled;
const result = await window.electronAPI.heartbeat.setEnabled(next);
if (result.ok) {
setEnabled(next);
} else {
setError(result.error ?? "Failed to update heartbeat setting");
}
}, [enabled]);
const wakeNow = useCallback(async () => {
setLoading(true);
try {
const result = await window.electronAPI.heartbeat.wake("manual");
if (!result.ok) {
setError(result.error ?? "Failed to run heartbeat");
}
await refresh();
} finally {
setLoading(false);
}
}, [refresh]);
return {
enabled,
lastEvent,
loading,
error,
refresh,
toggleEnabled,
wakeNow,
};
}

View file

@ -1,221 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
// ============================================================================
// Types matching the IPC response from main process
// ============================================================================
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'registered'
export interface HubInfo {
hubId: string
url: string
connectionState: ConnectionState
agentCount: number
}
export interface AgentInfo {
id: string
closed: boolean
}
export interface UseHubReturn {
/** Hub information */
hubInfo: HubInfo | null
/** List of agents */
agents: AgentInfo[]
/** Loading state */
loading: boolean
/** Error state */
error: string | null
/** Initialize the Hub (called automatically on mount) */
initHub: () => Promise<void>
/** Refresh Hub info and agents list */
refresh: () => Promise<void>
/** Reconnect to a different Gateway URL */
reconnect: (url: string) => Promise<void>
/** Create a new agent */
createAgent: (id?: string) => Promise<AgentInfo | null>
/** Close an agent */
closeAgent: (id: string) => Promise<boolean>
/** Send a message to an agent */
sendMessage: (agentId: string, content: string) => Promise<boolean>
}
/**
* Hook for managing Hub connection and agents via IPC.
*
* This hook communicates with the Electron main process to:
* - Initialize and manage the Hub singleton
* - Create, list, and close agents
* - Send messages to agents
*/
export function useHub(): UseHubReturn {
const [hubInfo, setHubInfo] = useState<HubInfo | null>(null)
const [agents, setAgents] = useState<AgentInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Initialize Hub and fetch info
const initHub = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Initialize Hub (use new electronAPI if available)
if (window.electronAPI) {
await window.electronAPI.hub.init()
const info = await window.electronAPI.hub.info()
setHubInfo(info as HubInfo)
const agentList = await window.electronAPI.hub.listAgents()
setAgents(agentList as AgentInfo[])
} else {
await window.ipcRenderer.invoke('hub:init')
const info = await window.ipcRenderer.invoke('hub:info')
setHubInfo(info)
const agentList = await window.ipcRenderer.invoke('hub:listAgents')
setAgents(agentList)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to initialize Hub')
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => {
initHub()
}, [initHub])
// Subscribe to connection state changes pushed from main process
useEffect(() => {
const handler = (state: string) => {
setHubInfo((prev) => prev ? { ...prev, connectionState: state as HubInfo['connectionState'] } : prev)
}
window.electronAPI?.hub.onConnectionStateChanged(handler)
return () => {
window.electronAPI?.hub.offConnectionStateChanged()
}
}, [])
// Refresh Hub info and agents
const refresh = useCallback(async () => {
try {
setError(null)
if (window.electronAPI) {
const info = await window.electronAPI.hub.info()
setHubInfo(info as HubInfo)
const agentList = await window.electronAPI.hub.listAgents()
setAgents(agentList as AgentInfo[])
} else {
const info = await window.ipcRenderer.invoke('hub:info')
setHubInfo(info)
const agentList = await window.ipcRenderer.invoke('hub:listAgents')
setAgents(agentList)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to refresh Hub info')
}
}, [])
// Reconnect to different Gateway
const reconnect = useCallback(async (url: string) => {
try {
setError(null)
if (window.electronAPI) {
await window.electronAPI.hub.reconnect(url)
} else {
await window.ipcRenderer.invoke('hub:reconnect', url)
}
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reconnect')
}
}, [refresh])
// Create a new agent
const createAgent = useCallback(async (id?: string): Promise<AgentInfo | null> => {
try {
setError(null)
const result = window.electronAPI
? await window.electronAPI.hub.createAgent(id)
: await window.ipcRenderer.invoke('hub:createAgent', id)
const typedResult = result as { error?: string; id?: string; closed?: boolean }
if (typedResult.error) {
setError(typedResult.error)
return null
}
// Refresh agents list
await refresh()
return result as AgentInfo
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create agent')
return null
}
}, [refresh])
// Close an agent
const closeAgent = useCallback(async (id: string): Promise<boolean> => {
try {
setError(null)
const result = window.electronAPI
? await window.electronAPI.hub.closeAgent(id)
: await window.ipcRenderer.invoke('hub:closeAgent', id)
const typedResult = result as { ok?: boolean }
if (!typedResult.ok) {
setError(`Failed to close agent: ${id}`)
return false
}
// Refresh agents list
await refresh()
return true
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to close agent')
return false
}
}, [refresh])
// Send message to agent
const sendMessage = useCallback(async (agentId: string, content: string): Promise<boolean> => {
try {
setError(null)
const result = window.electronAPI
? await window.electronAPI.hub.sendMessage(agentId, content)
: await window.ipcRenderer.invoke('hub:sendMessage', agentId, content)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return false
}
return true
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send message')
return false
}
}, [])
return {
hubInfo,
agents,
loading,
error,
initHub,
refresh,
reconnect,
createAgent,
closeAgent,
sendMessage,
}
}
export default useHub

View file

@ -1,101 +0,0 @@
/**
* Hook for managing LLM providers in the Desktop App.
*
* Provides functionality similar to CLI `/provider` command:
* - List all providers with status
* - Get current provider/model
* - Switch provider/model
*/
import { useState, useEffect, useCallback } from 'react'
// Types are defined in electron-env.d.ts and available globally
interface UseProviderReturn {
/** All providers with their status */
providers: ProviderStatus[]
/** Only available (configured) providers */
availableProviders: ProviderStatus[]
/** Current provider and model info */
current: CurrentProviderInfo | null
/** Loading state */
loading: boolean
/** Error message if any */
error: string | null
/** Refresh provider list and current status */
refresh: () => Promise<void>
/** Switch to a different provider (and optionally model) */
setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
/** Get metadata for a specific provider */
getProviderMeta: (providerId: string) => ProviderStatus | undefined
}
export function useProvider(): UseProviderReturn {
const [providers, setProviders] = useState<ProviderStatus[]>([])
const [current, setCurrent] = useState<CurrentProviderInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [providerList, currentInfo] = await Promise.all([
window.electronAPI.provider.list(),
window.electronAPI.provider.current(),
])
setProviders(providerList)
setCurrent(currentInfo)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
console.error('[useProvider] Failed to load providers:', message)
} finally {
setLoading(false)
}
}, [])
// Load providers on mount
useEffect(() => {
refresh()
}, [refresh])
const setProvider = useCallback(async (providerId: string, modelId?: string) => {
setError(null)
try {
const result = await window.electronAPI.provider.set(providerId, modelId)
if (result.ok) {
// Refresh to update current status
await refresh()
return { ok: true }
} else {
setError(result.error ?? 'Unknown error')
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
return { ok: false, error: message }
}
}, [refresh])
const getProviderMeta = useCallback((providerId: string) => {
return providers.find((p) => p.id === providerId)
}, [providers])
const availableProviders = providers.filter((p) => p.available)
return {
providers,
availableProviders,
current,
loading,
error,
refresh,
setProvider,
getProviderMeta,
}
}

View file

@ -1,264 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
// ============================================================================
// Types matching the IPC response from main process
// ============================================================================
export type SkillSource = 'bundled' | 'global' | 'profile'
export interface SkillInfo {
id: string
name: string
description: string
version: string
enabled: boolean
source: SkillSource
triggers: string[]
}
export interface SkillGroup {
source: SkillSource
name: string
skills: SkillInfo[]
}
// Source display names
const SOURCE_NAMES: Record<string, string> = {
bundled: 'Built-in Skills',
global: 'Global Skills',
profile: 'Profile Skills',
}
export interface UseSkillsReturn {
/** List of all skills */
skills: SkillInfo[]
/** Skills grouped by source */
groups: SkillGroup[]
/** Loading state */
loading: boolean
/** Error state */
error: string | null
/** Toggle a skill on/off */
toggleSkill: (skillId: string) => Promise<void>
/** Enable a skill */
enableSkill: (skillId: string) => Promise<void>
/** Disable a skill */
disableSkill: (skillId: string) => Promise<void>
/** Refresh skills list */
refresh: () => Promise<void>
/** Get skill by ID */
getSkill: (id: string) => SkillInfo | undefined
/** Filter skills by search query */
filterSkills: (query: string) => SkillInfo[]
/** Check if a skill is enabled */
isSkillEnabled: (skillId: string) => boolean
/** Stats */
stats: {
total: number
enabled: number
disabled: number
bundled: number
global: number
profile: number
}
}
/**
* Hook for managing Agent skills configuration via IPC.
*
* This hook communicates with the Electron main process to:
* - Fetch the list of all skills (bundled, global, profile)
* - Toggle skills on/off
* - Match the CLI `multica skills list` output
*/
export function useSkills(): UseSkillsReturn {
const [skills, setSkills] = useState<SkillInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch skills from main process
const fetchSkills = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Use new electronAPI if available, fallback to ipcRenderer
const result = window.electronAPI
? await window.electronAPI.skills.list()
: await window.ipcRenderer.invoke('skills:list')
if (Array.isArray(result)) {
setSkills(result)
} else {
setError('Invalid response from skills:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch skills')
setSkills([])
} finally {
setLoading(false)
}
}, [])
// Initial fetch
useEffect(() => {
fetchSkills()
}, [fetchSkills])
// Group skills by source
const groups = useMemo<SkillGroup[]>(() => {
const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile']
const groupMap = new Map<SkillSource, SkillInfo[]>()
for (const skill of skills) {
const sourceSkills = groupMap.get(skill.source) || []
sourceSkills.push(skill)
groupMap.set(skill.source, sourceSkills)
}
return sourceOrder
.filter((source) => groupMap.has(source))
.map((source) => ({
source,
name: SOURCE_NAMES[source] || source,
skills: groupMap.get(source) || [],
}))
}, [skills])
// Stats
const stats = useMemo(() => ({
total: skills.length,
enabled: skills.filter((s) => s.enabled).length,
disabled: skills.filter((s) => !s.enabled).length,
bundled: skills.filter((s) => s.source === 'bundled').length,
global: skills.filter((s) => s.source === 'global').length,
profile: skills.filter((s) => s.source === 'profile').length,
}), [skills])
// Toggle skill via IPC
const toggleSkill = useCallback(async (skillId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.skills.toggle(skillId)
: await window.ipcRenderer.invoke('skills:toggle', skillId)
const typedResult = result as { error?: string; enabled?: boolean }
if (typedResult.error) {
setError(typedResult.error)
return
}
// Update local state
setSkills((prev) =>
prev.map((skill) =>
skill.id === skillId ? { ...skill, enabled: typedResult.enabled ?? !skill.enabled } : skill
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to toggle skill')
}
}, [])
// Enable skill via IPC
const enableSkill = useCallback(async (skillId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.skills.setStatus(skillId, true)
: await window.ipcRenderer.invoke('skills:setStatus', skillId, true)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setSkills((prev) =>
prev.map((skill) =>
skill.id === skillId ? { ...skill, enabled: true } : skill
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to enable skill')
}
}, [])
// Disable skill via IPC
const disableSkill = useCallback(async (skillId: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.skills.setStatus(skillId, false)
: await window.ipcRenderer.invoke('skills:setStatus', skillId, false)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setSkills((prev) =>
prev.map((skill) =>
skill.id === skillId ? { ...skill, enabled: false } : skill
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disable skill')
}
}, [])
// Get skill by ID
const getSkill = useCallback(
(id: string): SkillInfo | undefined => {
return skills.find((s) => s.id === id)
},
[skills]
)
// Filter skills by search query
const filterSkills = useCallback(
(query: string): SkillInfo[] => {
if (!query.trim()) return skills
const lowerQuery = query.toLowerCase()
return skills.filter(
(skill) =>
skill.name.toLowerCase().includes(lowerQuery) ||
skill.id.toLowerCase().includes(lowerQuery) ||
skill.description.toLowerCase().includes(lowerQuery) ||
skill.triggers.some((t) => t.toLowerCase().includes(lowerQuery))
)
},
[skills]
)
// Check if skill is enabled
const isSkillEnabled = useCallback(
(skillId: string): boolean => {
const skill = skills.find((s) => s.id === skillId)
return skill?.enabled ?? false
},
[skills]
)
return {
skills,
groups,
loading,
error,
toggleSkill,
enableSkill,
disableSkill,
refresh: fetchSkills,
getSkill,
filterSkills,
isSkillEnabled,
stats,
}
}
export default useSkills

View file

@ -1,236 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
// ============================================================================
// Types matching the IPC response from main process
// ============================================================================
export interface ToolInfo {
name: string
description?: string
group: string
enabled: boolean
}
export interface ToolGroup {
id: string
name: string
tools: string[]
}
// Tool descriptions (for UI display)
const TOOL_DESCRIPTIONS: Record<string, string> = {
read: 'Read file contents',
write: 'Write content to file',
edit: 'Edit file with search/replace',
glob: 'Find files by pattern',
exec: 'Execute shell commands',
process: 'Manage background processes',
web_fetch: 'Fetch content from URLs',
web_search: 'Search the web via Devv Search',
memory_get: 'Get stored memory value',
memory_set: 'Store a memory value',
memory_delete: 'Delete a memory value',
memory_list: 'List all memory keys',
memory_search: 'Search memory files for keywords',
cron: 'Create and manage scheduled tasks',
}
// Group display names
const GROUP_NAMES: Record<string, string> = {
fs: 'File System',
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
}
export interface UseToolsReturn {
/** List of all tools with their status */
tools: ToolInfo[]
/** List of tool groups */
groups: ToolGroup[]
/** Loading state */
loading: boolean
/** Error state */
error: string | null
/** Toggle a specific tool on/off */
toggleTool: (toolName: string) => Promise<void>
/** Enable a tool */
enableTool: (toolName: string) => Promise<void>
/** Disable a tool */
disableTool: (toolName: string) => Promise<void>
/** Refresh tools list from main process */
refresh: () => Promise<void>
/** Check if a tool is enabled */
isToolEnabled: (toolName: string) => boolean
}
/**
* Hook for managing Agent tools configuration via IPC.
*
* This hook communicates with the Electron main process to:
* - Fetch the list of available tools and their status
* - Toggle tools on/off (persisted to credentials.json5)
* - Trigger agent.reloadTools() to apply changes immediately
*/
export function useTools(): UseToolsReturn {
const [tools, setTools] = useState<ToolInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch tools from main process
const fetchTools = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Use new electronAPI if available, fallback to ipcRenderer
const result = window.electronAPI
? await window.electronAPI.tools.list()
: await window.ipcRenderer.invoke('tools:list')
if (Array.isArray(result)) {
// Add descriptions to tools
const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({
...tool,
description: TOOL_DESCRIPTIONS[tool.name],
}))
setTools(toolsWithDesc)
} else {
setError('Invalid response from tools:list')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tools')
// Fallback to empty list
setTools([])
} finally {
setLoading(false)
}
}, [])
// Initial fetch
useEffect(() => {
fetchTools()
}, [fetchTools])
// Build groups list from tools
const groups = useMemo<ToolGroup[]>(() => {
const groupMap = new Map<string, string[]>()
for (const tool of tools) {
const groupTools = groupMap.get(tool.group) || []
groupTools.push(tool.name)
groupMap.set(tool.group, groupTools)
}
return Array.from(groupMap.entries()).map(([id, toolNames]) => ({
id,
name: GROUP_NAMES[id] || id,
tools: toolNames,
}))
}, [tools])
// Toggle tool via IPC
const toggleTool = useCallback(async (toolName: string) => {
console.log('[useTools] toggleTool called:', toolName)
try {
const result = window.electronAPI
? await window.electronAPI.tools.toggle(toolName)
: await window.ipcRenderer.invoke('tools:toggle', toolName)
console.log('[useTools] toggleTool result:', result)
const typedResult = result as { error?: string; enabled?: boolean }
if (typedResult.error) {
console.error('[useTools] toggleTool error:', typedResult.error)
setError(typedResult.error)
return
}
// Update local state
console.log('[useTools] Updating tool state:', toolName, 'enabled:', typedResult.enabled)
setTools((prev) =>
prev.map((tool) =>
tool.name === toolName ? { ...tool, enabled: typedResult.enabled ?? !tool.enabled } : tool
)
)
} catch (err) {
console.error('[useTools] toggleTool exception:', err)
setError(err instanceof Error ? err.message : 'Failed to toggle tool')
}
}, [])
// Enable tool via IPC
const enableTool = useCallback(async (toolName: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.tools.setStatus(toolName, true)
: await window.ipcRenderer.invoke('tools:setStatus', toolName, true)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setTools((prev) =>
prev.map((tool) =>
tool.name === toolName ? { ...tool, enabled: true } : tool
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to enable tool')
}
}, [])
// Disable tool via IPC
const disableTool = useCallback(async (toolName: string) => {
try {
const result = window.electronAPI
? await window.electronAPI.tools.setStatus(toolName, false)
: await window.ipcRenderer.invoke('tools:setStatus', toolName, false)
const typedResult = result as { error?: string }
if (typedResult.error) {
setError(typedResult.error)
return
}
setTools((prev) =>
prev.map((tool) =>
tool.name === toolName ? { ...tool, enabled: false } : tool
)
)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disable tool')
}
}, [])
// Check if tool is enabled
const isToolEnabled = useCallback(
(toolName: string): boolean => {
const tool = tools.find((t) => t.name === toolName)
return tool?.enabled ?? false
},
[tools]
)
return {
tools,
groups,
loading,
error,
toggleTool,
enableTool,
disableTool,
refresh: fetchTools,
isToolEnabled,
}
}
export default useTools

View file

@ -1,6 +1,7 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import "@multica/ui/fonts"
import "@multica/ui/globals.css"
ReactDOM.createRoot(document.getElementById('root')!).render(

View file

@ -0,0 +1,407 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import {
MessageSquare,
Link2,
Loader2,
AlertCircle,
Pencil,
ChevronDown,
Check,
AlertTriangle,
} 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 } from '../stores/hub'
import { useProviderStore } from '../stores/provider'
export default function HomePage() {
const navigate = useNavigate()
const { hubInfo, agents, loading, error } = useHubStore()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore()
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 [selectedProvider, setSelectedProvider] = useState<{
id: string
name: string
authMethod: 'api-key' | 'oauth'
loginCommand?: string
} | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// 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 (or create one if none exists)
const primaryAgent = agents[0]
// Connection state indicator
// Note: 'registered' means fully connected and registered with Gateway
const connectionState = hubInfo?.connectionState ?? 'disconnected'
const isConnected = connectionState === 'connected' || connectionState === 'registered'
// Loading state
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span>Connecting to Hub...</span>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-destructive">
<AlertCircle className="size-8" />
<span className="font-medium">Connection Error</span>
<span className="text-sm text-muted-foreground">{error}</span>
</div>
</div>
)
}
return (
<div className="h-full flex flex-col">
{/* Main content - QR + Status */}
<div className="flex-1 flex gap-8 p-2">
{/* Left: QR Code */}
<div className="flex-1 flex flex-col items-center justify-center">
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
expirySeconds={30}
size={180}
/>
</div>
{/* Right: Hub Status */}
<div className="flex-1 flex flex-col justify-center">
<div className="space-y-6">
{/* Hub Header */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="relative flex size-2.5">
{isConnected ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-green-500" />
</>
) : connectionState === 'connecting' || connectionState === 'reconnecting' ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-yellow-500" />
</>
) : (
<span className="relative inline-flex rounded-full size-2.5 bg-red-500" />
)}
</span>
<span className={`text-sm font-medium ${
isConnected
? 'text-green-600 dark:text-green-400'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
}`}>
{isConnected
? 'Hub Connected'
: connectionState === 'connecting'
? 'Connecting...'
: connectionState === 'reconnecting'
? 'Reconnecting...'
: 'Disconnected'}
</span>
</div>
<h2 className="text-2xl font-semibold tracking-tight">
Local Hub
</h2>
<p className="text-sm text-muted-foreground font-mono">
{hubInfo?.hubId ?? 'Initializing...'}
</p>
</div>
{/* Agent Settings */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Agent Settings
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSettingsOpen(true)}
>
<Pencil className="size-4" />
</Button>
</div>
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
</div>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
LLM Provider
</p>
<button
className="w-full flex items-center justify-between p-3 rounded-md bg-background border border-border hover:bg-accent/50 transition-colors disabled:opacity-50"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={providerLoading || switching}
>
<div className="flex items-center gap-2">
{current?.available ? (
<Check className="size-4 text-green-500" />
) : (
<AlertTriangle className="size-4 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-sm">{current?.providerName ?? current?.provider ?? 'Loading...'}</p>
<p className="text-xs text-muted-foreground">{current?.model ?? '-'}</p>
</div>
</div>
<ChevronDown
className={`size-4 text-muted-foreground transition-transform ${providerDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Provider Dropdown - Compact Grid + Model List */}
{providerDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-1 z-10 bg-background border border-border rounded-md shadow-lg p-2 max-h-[60vh] overflow-y-auto">
<div className="grid grid-cols-3 gap-1.5">
{providers.map((p) => (
<button
key={p.id}
className={`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) {
// Show config dialog for unavailable providers
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}
title={`${p.name}\n${p.authMethod === 'oauth' ? 'OAuth' : 'API Key'} · ${p.defaultModel}`}
>
<span className={`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 for current provider */}
{(() => {
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 {currentProvider.name}
</p>
<div className="space-y-0.5">
{currentProvider.models.map((model) => (
<button
key={model}
className={`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={`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>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Gateway
</p>
<p className="font-medium text-sm truncate" title={hubInfo?.url}>
{hubInfo?.url ?? '-'}
</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Connection
</p>
<p className="font-medium capitalize">{connectionState}</p>
</div>
</div>
</div>
</div>
</div>
{/* Verified Devices */}
<div className="px-4 pb-2">
<DeviceList />
</div>
{/* Agent Settings Dialog */}
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
{/* API Key Dialog */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{/* OAuth Dialog */}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
}
}}
/>
)}
{/* Bottom: Actions */}
<div className="border-t p-4">
<div className="flex items-center justify-between">
{/* Primary Action: Chat */}
<Button
size="lg"
className="gap-2 px-6"
onClick={() => navigate('/chat')}
disabled={!isConnected}
>
<MessageSquare className="size-5" />
Open Chat
</Button>
{/* Secondary: Connect to Remote */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground gap-1.5"
>
<Link2 className="size-4" />
Connect to Remote Agent
</Button>
</div>
</div>
</div>
)
}

View file

@ -9,7 +9,7 @@ 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 { useChannels, type UseChannelsReturn } from '../hooks/use-channels'
import { useChannelsStore } from '../stores/channels'
/** Status badge color mapping */
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
@ -21,8 +21,8 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
}
}
function TelegramCard({ channels }: { channels: UseChannelsReturn }) {
const { states, config, saveToken, removeToken, startChannel, stopChannel } = channels
function TelegramCard() {
const { states, config, saveToken, removeToken, startChannel, stopChannel } = useChannelsStore()
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
@ -153,25 +153,30 @@ function TelegramCard({ channels }: { channels: UseChannelsReturn }) {
}
export default function ChannelsPage() {
const channels = useChannels()
const { loading, error } = channels
const { loading, error } = useChannelsStore()
return (
<div className="max-w-4xl mx-auto space-y-4">
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<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">
Connect messaging platforms to your Agent.
Channels let you talk to your agent from other platforms like Telegram or Slack. Connect one to chat with your agent anywhere.
</p>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<TelegramCard channels={channels} />
)}
{/* 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>
</div>
)
}

View file

@ -1,128 +1,13 @@
import { Button } from '@multica/ui/components/ui/button'
import { RemoteChat } from '../components/remote-chat'
import { useSearchParams } from 'react-router-dom'
import { LocalChat } from '../components/local-chat'
import { useChatModeStore } from '../stores/chat-mode'
import { useGatewayConnection, type UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection'
function ModeNav({ gateway }: { gateway: UseGatewayConnectionReturn }) {
const { mode, setMode } = useChatModeStore()
if (mode === 'select') return null
return (
<div className="flex items-center gap-1 px-6 py-1 shrink-0">
<NavButton active={mode === 'local'} onClick={() => setMode('local')}>
Local
</NavButton>
<NavButton active={mode === 'remote'} onClick={() => setMode('remote')}>
Remote
</NavButton>
{mode === 'remote' && gateway.pageState === 'connected' && (
<>
<div className="flex-1" />
<button
onClick={gateway.disconnect}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disconnect
</button>
</>
)}
</div>
)
}
function NavButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
active
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
{children}
</button>
)
}
function ModeSelect() {
const setMode = useChatModeStore((s) => s.setMode)
return (
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
<div className="text-center space-y-2">
<h2 className="text-lg font-semibold">Start a Conversation</h2>
<p className="text-sm text-muted-foreground">
Choose how you want to connect
</p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<Button
size="lg"
onClick={() => setMode('local')}
className="w-full"
>
Local Agent
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
</Button>
<Button
size="lg"
variant="outline"
onClick={() => setMode('remote')}
className="w-full"
>
Remote Agent
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
</Button>
</div>
</div>
)
}
export default function ChatPage() {
const mode = useChatModeStore((s) => s.mode)
const gateway = useGatewayConnection()
const [searchParams] = useSearchParams()
const initialPrompt = searchParams.get('prompt') ?? undefined
return (
<div className="h-full flex flex-col overflow-hidden">
<ModeNav gateway={gateway} />
{mode === 'select' && <ModeSelect />}
{mode === 'local' && <LocalChat />}
<ChatPanel visible={mode === 'remote'}>
<RemoteChat gateway={gateway} />
</ChatPanel>
</div>
)
}
function ChatPanel({
visible,
children,
}: {
visible: boolean
children: React.ReactNode
}) {
return (
<div
className={`flex-1 min-h-0 ${visible ? 'flex flex-col' : 'hidden'}`}
>
{children}
<LocalChat initialPrompt={initialPrompt} />
</div>
)
}

View file

@ -1,43 +1,32 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@multica/ui/components/ui/card'
import { useCronJobs } from '../hooks/use-cron-jobs'
import { useCronJobsStore } from '../stores/cron-jobs'
import { CronJobList } from '../components/cron-job-list'
export default function CronsPage() {
const {
jobs,
loading,
error,
toggleJob,
removeJob,
refresh,
} = useCronJobs()
const { jobs, loading, error, toggleJob, removeJob, refresh } = useCronJobsStore()
return (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Cron Jobs</CardTitle>
<CardDescription>
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
</CardDescription>
</CardHeader>
<CardContent>
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</CardContent>
</Card>
<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">Scheduled Tasks</h1>
<p className="text-sm text-muted-foreground">
Scheduled tasks run automatically at set times. Ask your agent to create one, like "remind me every morning" or "check my inbox daily."
</p>
</div>
{/* Configuration Area */}
<div className="flex-1 min-h-0">
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</div>
</div>
</div>
)
}

View file

@ -1,35 +1,67 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Comment01Icon,
LinkSquare01Icon,
Loading03Icon,
AlertCircleIcon,
Edit02Icon,
ArrowDown01Icon,
Tick02Icon,
Alert02Icon,
} from '@hugeicons/core-free-icons'
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,
} 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 { useHub } from '../hooks/use-hub'
import { useProvider } from '../hooks/use-provider'
import { useHubStore, selectPrimaryAgent } 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, error } = useHub()
const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider()
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()
// 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
@ -38,6 +70,11 @@ export default function HomePage() {
} | 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) => {
@ -76,273 +113,512 @@ export default function HomePage() {
}
}
// Get the first agent (or create one if none exists)
const primaryAgent = agents[0]
// Get the first agent
const primaryAgent = selectPrimaryAgent(agents)
// Connection state indicator
// Note: 'registered' means fully connected and registered with Gateway
const connectionState = hubInfo?.connectionState ?? 'disconnected'
const isConnected = connectionState === 'connected' || connectionState === 'registered'
// Agent status: running if app is open, warning if no LLM provider
const isProviderAvailable = current?.available ?? false
const agentReady = !providerLoading && isProviderAvailable
// Loading state
if (loading) {
// Loading state (only while provider info is loading)
if (loading || providerLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<HugeiconsIcon icon={Loading03Icon} className="size-5 animate-spin" />
<span>Connecting to Hub...</span>
<Loader2 className="size-5 animate-spin" />
<span>Starting agent...</span>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-8" />
<span className="font-medium">Connection Error</span>
<span className="text-sm text-muted-foreground">{error}</span>
</div>
</div>
)
// 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 flex flex-col">
{/* Main content - QR + Status */}
<div className="flex-1 flex gap-8 p-2">
{/* Left: QR Code */}
<div className="flex-1 flex flex-col items-center justify-center">
<ConnectionQRCode
gateway={hubInfo?.url ?? 'http://localhost:3000'}
hubId={hubInfo?.hubId ?? 'unknown'}
agentId={primaryAgent?.id}
expirySeconds={30}
size={180}
/>
<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>
{/* Row 1: Status + Chat (Left) | Agent Settings (Right) */}
<div className="flex gap-8 mb-6">
{/* Left: Status + Chat */}
<div className="flex-1">
{/* Status */}
<div className="flex items-center gap-2 mb-1 mt-4">
<span className="relative flex size-2">
{agentReady ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-green-500" />
</>
) : (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2 bg-yellow-500" />
</>
)}
</span>
<span className={cn(
'font-medium',
agentReady
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
)}>
{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.'}
</p>
<Button
variant="outline"
size="lg"
className="gap-2"
onClick={() => navigate('/chat')}
disabled={!agentReady}
>
Start Chat
<ArrowRight className="size-4" />
</Button>
</div>
{/* Right: Hub Status */}
<div className="flex-1 flex flex-col justify-center">
<div className="space-y-6">
{/* Hub Header */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="relative flex size-2.5">
{isConnected ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-green-500" />
</>
) : connectionState === 'connecting' || connectionState === 'reconnecting' ? (
<>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex rounded-full size-2.5 bg-yellow-500" />
</>
) : (
<span className="relative inline-flex rounded-full size-2.5 bg-red-500" />
)}
</span>
<span className={`text-sm font-medium ${
isConnected
? 'text-green-600 dark:text-green-400'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
}`}>
{isConnected
? 'Hub Connected'
: connectionState === 'connecting'
? 'Connecting...'
: connectionState === 'reconnecting'
? 'Reconnecting...'
: 'Disconnected'}
</span>
</div>
<h2 className="text-2xl font-semibold tracking-tight">
Local Hub
</h2>
<p className="text-sm text-muted-foreground font-mono">
{hubInfo?.hubId ?? 'Initializing...'}
</p>
</div>
{/* Vertical Divider */}
<div className="w-px bg-border" />
{/* Agent Settings */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Agent Settings
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setSettingsOpen(true)}
>
<HugeiconsIcon icon={Edit02Icon} className="size-4" />
</Button>
</div>
<p className="font-medium">{agentName || 'Unnamed Agent'}</p>
</div>
{/* 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>
<Button
variant="outline"
className="w-full justify-between"
onClick={() => setSettingsOpen(true)}
>
<span>{agentName || 'Unnamed Agent'}</span>
<Pencil className="size-4 text-muted-foreground" />
</Button>
</div>
{/* Provider Selector */}
<div className="p-4 rounded-lg bg-muted/50 border border-border/50 relative" ref={dropdownRef}>
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
LLM Provider
</p>
<button
className="w-full flex items-center justify-between p-3 rounded-md bg-background border border-border hover:bg-accent/50 transition-colors disabled:opacity-50"
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
disabled={providerLoading || switching}
>
<div className="flex items-center gap-2">
{current?.available ? (
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-green-500" />
) : (
<HugeiconsIcon icon={Alert02Icon} className="size-4 text-yellow-500" />
)}
<div className="text-left">
<p className="font-medium text-sm">{current?.providerName ?? current?.provider ?? 'Loading...'}</p>
<p className="text-xs text-muted-foreground">{current?.model ?? '-'}</p>
</div>
</div>
<HugeiconsIcon
icon={ArrowDown01Icon}
className={`size-4 text-muted-foreground transition-transform ${providerDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* 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'
)}
/>
</Button>
{/* Provider Dropdown - Compact Grid + Model List */}
{providerDropdownOpen && (
<div className="absolute left-0 right-0 top-full mt-1 z-10 bg-background border border-border rounded-md shadow-lg p-2 max-h-[60vh] overflow-y-auto">
<div className="grid grid-cols-3 gap-1.5">
{providers.map((p) => (
<button
key={p.id}
className={`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) {
// Show config dialog for unavailable providers
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)
{/* 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)
const result = await setProvider(p.id)
setSwitching(false)
if (!result.ok) {
console.error('Failed to switch provider:', result.error)
if (p.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
}}
disabled={switching}
title={`${p.name}\n${p.authMethod === 'oauth' ? 'OAuth' : 'API Key'} · ${p.defaultModel}`}
>
<span className={`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 for current provider */}
{(() => {
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 {currentProvider.name}
</p>
<div className="space-y-0.5">
{currentProvider.models.map((model) => (
<button
key={model}
className={`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={`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>
)
})()}
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>
)}
</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>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Gateway
</p>
<p className="font-medium text-sm truncate" title={hubInfo?.url}>
{hubInfo?.url ?? '-'}
</p>
</div>
<div className="p-4 rounded-lg bg-muted/50 border border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
Connection
</p>
<p className="font-medium capitalize">{connectionState}</p>
</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>
{/* Verified Devices */}
<div className="px-4 pb-2">
<DeviceList />
</div>
{/* Agent Settings Dialog */}
{/* Dialogs */}
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
{/* API Key Dialog */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
@ -350,7 +626,6 @@ export default function HomePage() {
providerId={selectedProvider.id}
providerName={selectedProvider.name}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
@ -360,7 +635,6 @@ export default function HomePage() {
/>
)}
{/* OAuth Dialog */}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
@ -369,7 +643,6 @@ export default function HomePage() {
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={async () => {
// Refresh provider list and switch to the newly configured provider
await refresh()
const result = await setProvider(selectedProvider.id)
if (!result.ok) {
@ -378,32 +651,7 @@ export default function HomePage() {
}}
/>
)}
{/* Bottom: Actions */}
<div className="border-t p-4">
<div className="flex items-center justify-between">
{/* Primary Action: Chat */}
<Button
size="lg"
className="gap-2 px-6"
onClick={() => navigate('/chat')}
disabled={!isConnected}
>
<HugeiconsIcon icon={Comment01Icon} className="size-5" />
Open Chat
</Button>
{/* Secondary: Connect to Remote */}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground gap-1.5"
>
<HugeiconsIcon icon={LinkSquare01Icon} className="size-4" />
Connect to Remote Agent
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -1,92 +1,212 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom'
import { Toaster } from '@multica/ui/components/ui/sonner'
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { MulticaIcon } from '@multica/ui/components/multica-icon'
import {
Settings02Icon,
Home01Icon,
CodeIcon,
PlugIcon,
Comment01Icon,
Share08Icon,
Time04Icon,
} from '@hugeicons/core-free-icons'
Home,
MessageSquare,
Puzzle,
Wrench,
Radio,
Clock,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@multica/ui/components/ui/sidebar'
import { cn } from '@multica/ui/lib/utils'
import { ModeToggle } from '../components/mode-toggle'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
import ChatPage from './chat'
const tabs = [
{ path: '/', label: 'Home', icon: Home01Icon, exact: true },
{ path: '/chat', label: 'Chat', icon: Comment01Icon },
{ path: '/tools', label: 'Tools', icon: CodeIcon },
{ path: '/skills', label: 'Skills', icon: PlugIcon },
{ path: '/channels', label: 'Channels', icon: Share08Icon },
{ path: '/crons', label: 'Cron', icon: Time04Icon },
const mainNavItems = [
{ path: '/', label: 'Home', icon: Home },
{ 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 },
]
// All nav items for header lookup
const allNavItems = [...mainNavItems, ...configNavItems]
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
const canGoBack = historyIdx > 0
const canGoForward = historyIdx < window.history.length - 1
return (
<div
className="flex items-center gap-0.5 ml-auto mr-2"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(-1)}
disabled={!canGoBack}
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(1)}
disabled={!canGoForward}
>
<ChevronRight />
</Button>
</div>
)
}
function MainHeader() {
const { state, isMobile } = useSidebar()
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)
)
return (
<header className="h-12 shrink-0 flex items-center px-4">
{/* Drag placeholder for traffic lights when sidebar is collapsed */}
<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 />
{/* Center: Current page */}
<div className="flex-1 flex justify-center">
{currentPage && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<currentPage.icon className="size-4" />
<span>{currentPage.label}</span>
</div>
)}
</div>
{/* Right: Theme toggle */}
<ModeToggle />
</header>
)
}
export default function Layout() {
const location = useLocation()
return (
<div className="h-dvh flex flex-col bg-background">
{/* Header with drag region for macOS */}
<header
className="flex items-center justify-between px-4 py-3 border-b pl-20"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">Multica</span>
</div>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
<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}
>
<Button variant="ghost" size="icon">
<HugeiconsIcon icon={Settings02Icon} className="size-5" />
</Button>
</div>
</header>
<NavigationButtons />
</SidebarHeader>
{/* Tabs */}
<nav className="flex gap-1 px-4 py-2 border-b">
{tabs.map((tab) => {
const isActive = tab.exact
? location.pathname === tab.path
: location.pathname.startsWith(tab.path)
return (
<NavLink key={tab.path} to={tab.path}>
<Button
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={cn('gap-2', isActive && 'bg-secondary')}
>
<HugeiconsIcon icon={tab.icon} className="size-4" />
{tab.label}
</Button>
</NavLink>
)
})}
</nav>
{/* Content */}
<main className="flex-1 overflow-auto relative">
{/* ChatPage is always mounted (cached), hidden via CSS */}
<div className={cn('absolute inset-0', location.pathname === '/chat' ? '' : 'hidden')}>
<ChatPage />
</div>
{/* Other routes render normally via Outlet */}
{location.pathname !== '/chat' && (
<div className="p-4 h-full">
<Outlet />
<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>
)}
</main>
<Toaster />
<DeviceConfirmDialog />
<UpdateNotification />
{/* 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>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
{/* 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>
<SidebarInset className="overflow-hidden">
<MainHeader />
{/* Main Content */}
<main className="flex-1 overflow-hidden min-h-1">
<Outlet />
</main>
</SidebarInset>
<DeviceConfirmDialog />
<UpdateNotification />
</SidebarProvider>
</div>
)
}

View file

@ -0,0 +1,219 @@
import { useState } from 'react'
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 { Separator } from '@multica/ui/components/ui/separator'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@multica/ui/components/ui/hover-card'
import {
ChevronLeft,
Loader2,
HelpCircle,
Share2,
Check,
Info,
} from 'lucide-react'
import { useChannelsStore } from '../../../stores/channels'
import { StepDots } from './step-dots'
function statusVariant(
status: string
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'running':
return 'default'
case 'starting':
return 'secondary'
case 'error':
return 'destructive'
default:
return 'outline'
}
}
interface ConnectStepProps {
onNext: () => void
onBack: () => void
}
export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
const { states, config, saveToken } = useChannelsStore()
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const state = states.find(
(s) => s.channelId === 'telegram' && s.accountId === 'default'
)
const savedConfig = config['telegram']?.['default'] as
| { botToken?: string }
| undefined
const hasToken = Boolean(savedConfig?.botToken)
const isRunning = state?.status === 'running'
const isStarting = state?.status === 'starting'
const handleConnect = async () => {
if (!token.trim()) return
setSaving(true)
setLocalError(null)
const result = await saveToken('telegram', 'default', token.trim())
if (!result.ok) {
setLocalError(result.error ?? 'Failed to connect')
} else {
setToken('')
}
setSaving(false)
}
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-6">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Back
</button>
{/* Header */}
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
Connect a channel
</h1>
<p className="text-sm text-muted-foreground">
Create bots that talk to your local agent from anywhere.
</p>
</div>
{/* Info box */}
<div className="rounded-lg bg-muted/50 px-4 py-3 space-y-2">
<p className="text-sm text-muted-foreground">
Your bot connects directly to this machine
chat from your phone, tablet, or any device.
</p>
<p className="text-xs text-muted-foreground/70 flex items-center gap-1.5">
<Info className="size-3.5 shrink-0" />
Telegram now. Discord, Slack, Mobile app coming soon.
</p>
</div>
{/* Telegram card */}
<div className="rounded-xl border border-border bg-card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<Share2 className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Telegram</p>
<p className="text-xs text-muted-foreground">
Bot API long polling
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Status badge */}
{state && (
<Badge variant={statusVariant(state.status)}>
{state.status}
</Badge>
)}
{/* Help hover card */}
<HoverCard>
<HoverCardTrigger className="p-1 text-muted-foreground hover:text-foreground transition-colors">
<HelpCircle className="size-4" />
</HoverCardTrigger>
<HoverCardContent align="end" side="top" className="w-56">
<p className="font-medium text-sm mb-2">
Get a bot token
</p>
<ol className="space-y-1.5">
<li className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">1.</span>
<span>Open @BotFather in Telegram</span>
</li>
<li className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">2.</span>
<span>Send /newbot and name your bot</span>
</li>
<li className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">3.</span>
<span>Copy the token and paste below</span>
</li>
</ol>
</HoverCardContent>
</HoverCard>
</div>
</div>
<div className="p-4">
{hasToken ? (
<div className="flex items-center gap-2">
<Check className="size-4 text-green-600 dark:text-green-500 shrink-0" />
<p className="text-sm text-muted-foreground">
{isRunning
? 'Bot is running. Send it a message to test.'
: isStarting
? 'Starting bot...'
: 'Bot configured.'}
</p>
</div>
) : (
<div className="flex gap-2">
<Input
type="password"
placeholder="Bot token from @BotFather"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
className="flex-1"
/>
<Button
size="sm"
variant='ghost'
onClick={handleConnect}
disabled={saving || !token.trim()}
>
{saving && (
<Loader2 className="size-4 animate-spin mr-1.5" />
)}
Connect
</Button>
</div>
)}
{localError && (
<p className="text-sm text-destructive mt-2">{localError}</p>
)}
{state?.status === 'error' && state.error && (
<p className="text-sm text-destructive mt-2">{state.error}</p>
)}
</div>
</div>
<Separator />
{/* Footer */}
<div className="flex items-center justify-between">
<StepDots />
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onNext}>
Skip
</Button>
<Button size="sm" onClick={onNext} disabled={!hasToken}>
Continue
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,101 @@
import { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { Checkbox } from '@multica/ui/components/ui/checkbox'
import { Separator } from '@multica/ui/components/ui/separator'
import {
FolderOpen,
Terminal,
Brain,
Database,
} from 'lucide-react'
import { StepDots } from './step-dots'
const capabilities = [
{
icon: FolderOpen,
title: 'File access',
description: 'Read & write files to complete tasks you assign',
},
{
icon: Terminal,
title: 'Shell commands',
description: 'Run commands — every one requires your approval',
},
{
icon: Brain,
title: 'LLM requests',
description: 'Send prompts using your API key directly',
},
{
icon: Database,
title: 'Local storage',
description: 'Sessions & credentials saved in ~/.super-multica/',
},
]
interface PermissionsStepProps {
onNext: () => void
}
export default function PermissionsStep({ onNext }: PermissionsStepProps) {
const [agreed, setAgreed] = useState(false)
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Privacy & trust
</h1>
<p className="text-sm text-muted-foreground">
Multica works locally on your machine. Here's what it can do.
</p>
</div>
{/* Capabilities card */}
<div className="rounded-xl border border-border bg-card divide-y divide-border">
{capabilities.map((item) => (
<div key={item.title} className="flex items-start gap-3 p-4">
<div className="mt-0.5 flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<item.icon className="size-4 text-muted-foreground" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{item.description}
</p>
</div>
</div>
))}
</div>
{/* Trust note */}
<div className="rounded-lg bg-muted/50 px-4 py-3">
<p className="text-sm text-muted-foreground">
Everything stays on your machine. We have no servers and can't see your data.
</p>
</div>
<Separator />
{/* Footer: dots on left, checkbox + button on right */}
<div className="flex items-center justify-between pt-4">
<StepDots />
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={agreed}
onCheckedChange={(checked) => setAgreed(checked === true)}
/>
I understand
</label>
<Button size="sm" onClick={onNext} disabled={!agreed}>
Continue
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,257 @@
import React, { useState } from 'react'
import { Button } from '@multica/ui/components/ui/button'
import { Separator } from '@multica/ui/components/ui/separator'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@multica/ui/components/ui/hover-card'
import { Link } from '@multica/ui/components/ui/link'
import { ChevronLeft, HelpCircle, Loader2 } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import { useProviderStore } from '../../../stores/provider'
import { ApiKeyDialog } from '../../../components/api-key-dialog'
import { OAuthDialog } from '../../../components/oauth-dialog'
import { StepDots } from './step-dots'
import { useOnboardingStore } from '../../../stores/onboarding'
const SUPPORTED_PROVIDERS = ['kimi-coding', 'claude-code', 'openai-codex', 'openrouter']
interface SetupStepProps {
onNext: () => void
onBack: () => void
}
export default function SetupStep({ onNext, onBack }: SetupStepProps) {
const { providers, current, loading, error, refresh, setProvider } =
useProviderStore()
const { setProviderConfigured } = useOnboardingStore()
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const [selectedProvider, setSelectedProvider] =
useState<ProviderStatus | null>(null)
const [switchingId, setSwitchingId] = useState<string | null>(null)
const hasActiveProvider = current?.available === true
const filteredProviders = SUPPORTED_PROVIDERS
.map((id) => providers.find((p) => p.id === id))
.filter((p): p is ProviderStatus => p != null)
const handleConfigure = (provider: ProviderStatus) => {
setSelectedProvider(provider)
if (provider.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
}
const handleSelect = async (provider: ProviderStatus) => {
if (provider.available && !switchingId) {
setSwitchingId(provider.id)
await setProvider(provider.id, undefined, { silent: true })
setSwitchingId(null)
}
}
const handleProviderSuccess = async (modelId?: string) => {
await refresh()
if (selectedProvider) {
await setProvider(selectedProvider.id, modelId)
}
setProviderConfigured(true)
}
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-6">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Back
</button>
{/* Header */}
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Connect a provider
</h1>
<p className="text-sm text-muted-foreground">
Multica needs an LLM provider to work. Add your API key.
</p>
</div>
{/* Provider cards */}
<div className="rounded-xl border border-border bg-card divide-y divide-border">
{loading && filteredProviders.length === 0 ? (
[1, 2, 3, 4].map((i) => (
<div key={i} className="h-14 animate-pulse bg-muted/30" />
))
) : (
filteredProviders.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
isActive={Boolean(current?.available && current.provider === provider.id)}
isSwitching={switchingId === provider.id}
onSelect={() => handleSelect(provider)}
onConfigure={() => handleConfigure(provider)}
/>
))
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Trust note */}
<div className="rounded-lg bg-muted/50 px-4 py-3">
<p className="text-sm text-muted-foreground">
API keys stay local. Stored in{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
~/.super-multica/
</code>{' '}
and never leave your device.
</p>
</div>
<Separator />
{/* Footer */}
<div className="flex items-center justify-between">
<StepDots />
<Button size="sm" onClick={onNext} disabled={!hasActiveProvider}>
Continue
</Button>
</div>
</div>
{/* Dialogs */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
showModelInput={selectedProvider.id === 'openrouter'}
onSuccess={handleProviderSuccess}
/>
)}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={handleProviderSuccess}
/>
)}
</div>
)
}
function ProviderRow({
provider,
isActive,
isSwitching,
onSelect,
onConfigure,
}: {
provider: ProviderStatus
isActive: boolean
isSwitching: boolean
onSelect: () => void
onConfigure: () => void
}) {
const getTutorialSteps = (): React.ReactNode[] => {
if (provider.authMethod === 'oauth') {
return [
<span key="1">Run: <code className="bg-muted px-1 py-0.5 rounded text-xs">{provider.loginCommand}</code></span>,
'Complete login in browser',
'Click Configure → Refresh',
]
}
return [
provider.loginUrl ? (
<span key="1">Go to <Link href={provider.loginUrl}>{new URL(provider.loginUrl).hostname}</Link></span>
) : (
'Go to provider dashboard'
),
'Create a new API key',
'Click Configure and paste',
]
}
return (
<div
onClick={provider.available ? onSelect : undefined}
className={cn(
'flex items-center justify-between px-4 py-3 transition-colors',
provider.available && 'cursor-pointer hover:bg-accent/50'
)}
>
<div className="flex items-center gap-3">
{/* Radio indicator */}
<div
className={cn(
'size-4 rounded-full flex items-center justify-center shrink-0 transition-colors',
isSwitching ? '' : 'border-2',
isActive ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{isSwitching ? (
<Loader2 className="size-4 animate-spin text-primary" />
) : isActive ? (
<div className="size-2 rounded-full bg-primary" />
) : null}
</div>
<div>
<p className="text-sm font-medium">{provider.name}</p>
<p className="text-xs text-muted-foreground">{provider.defaultModel}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Help hover card */}
<HoverCard>
<HoverCardTrigger
onClick={(e) => e.stopPropagation()}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="size-4" />
</HoverCardTrigger>
<HoverCardContent align="end" side="top" className="w-56">
<p className="font-medium text-sm mb-2">Setup {provider.name}</p>
<ol className="space-y-1.5">
{getTutorialSteps().map((step, i) => (
<li key={i} className="text-xs text-muted-foreground flex gap-2">
<span className="text-foreground/50 shrink-0">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
</HoverCardContent>
</HoverCard>
{/* Configure button */}
<Button
size="sm"
variant="outline"
className={provider.available ? 'text-green-600 hover:text-green-600 dark:text-green-500 dark:hover:text-green-500' : ''}
onClick={(e) => {
e.stopPropagation()
onConfigure()
}}
>
{provider.available ? 'Configured' : 'Configure'}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { cn } from "@multica/ui/lib/utils"
import { useOnboardingStore } from "../../../stores/onboarding"
const TOTAL_STEPS = 4
export function StepDots() {
const currentStep = useOnboardingStore((s) => s.currentStep)
return (
<div className="flex items-center gap-1.5">
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const step = i + 1 // steps are 1-based (1, 2, 3, 4)
const isActive = step === currentStep
const isCompleted = step < currentStep
return (
<div
key={step}
className={cn(
"size-1.5 rounded-full transition-colors",
isActive && "bg-foreground",
isCompleted && "bg-foreground/50",
!isActive && !isCompleted && "bg-muted-foreground/30"
)}
/>
)
})}
</div>
)
}

View file

@ -0,0 +1,111 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { Separator } from '@multica/ui/components/ui/separator'
import {
ChevronLeft,
ArrowRight,
Search,
FolderOpen,
Terminal,
} from 'lucide-react'
import { StepDots } from './step-dots'
const tryPrompts = [
{
icon: Search,
title: 'Search the web',
description: "Get today's AI news",
prompt: "Search the web for today's top AI news and give me a 3-bullet summary with sources.",
},
{
icon: FolderOpen,
title: 'Read your files',
description: 'Summarize this directory',
prompt: 'Look at the files in my current directory and give me a brief summary of what this project is about.',
},
{
icon: Terminal,
title: 'Run a command',
description: 'Show system info',
prompt: 'Write a one-liner shell command that shows my system info (OS, CPU cores, memory) and run it.',
},
]
interface TryItStepProps {
onComplete: () => void | Promise<void>
onBack: () => void
}
export default function TryItStep({ onComplete, onBack }: TryItStepProps) {
const navigate = useNavigate()
const handlePromptClick = async (prompt: string) => {
console.log('[TryItStep] Selected prompt:', prompt)
await onComplete()
navigate(`/chat?prompt=${encodeURIComponent(prompt)}`)
}
return (
<div className="h-full flex items-center justify-center px-6 py-8 animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-6">
{/* Back button */}
<button
onClick={onBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Back
</button>
{/* Header */}
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
🎉 Ready to go
</h1>
<p className="text-sm text-muted-foreground">
Your agent is ready. Try a sample task or dive right in.
</p>
</div>
{/* Try prompts */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Quick start
</p>
<div className="rounded-xl border border-border bg-card divide-y divide-border">
{tryPrompts.map((item) => (
<button
key={item.title}
onClick={() => handlePromptClick(item.prompt)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-accent/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<item.icon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</div>
</div>
<ArrowRight className="size-4 text-muted-foreground" />
</button>
))}
</div>
</div>
<Separator />
{/* Footer */}
<div className="flex items-center justify-between">
<StepDots />
<Button size="sm" onClick={onComplete}>
Go to Multica
</Button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,65 @@
import { Button } from '@multica/ui/components/ui/button'
import { MulticaIcon } from '@multica/ui/components/multica-icon'
const features = [
{
title: 'Your AI',
description: 'Choose your preferred model. Extend its abilities with Skills.',
},
{
title: 'Your Machine',
description: 'Runs locally on your computer. Your data stays with you.',
},
{
title: 'Your Control',
description: 'You set the boundaries. The AI works within them.',
},
]
interface WelcomeStepProps {
onStart: () => void
}
export default function WelcomeStep({ onStart }: WelcomeStepProps) {
return (
<div className="h-full flex items-center justify-center px-12 py-8 animate-in fade-in duration-300">
<div className="max-w-md w-full flex flex-col items-center text-center space-y-6">
{/* Brand Title */}
<div className="flex items-center gap-2.5">
<MulticaIcon animate className="size-4 text-muted-foreground/70" />
<h1 className="text-2xl tracking-wide font-brand">
Welcome to Multica
</h1>
</div>
{/* Intro */}
<p className="text-sm text-muted-foreground leading-relaxed">
An AI assistant that gets things done pulling data, running analysis,
and taking action. Talk to it like a team member.
</p>
{/* Feature List */}
<div className="w-full bg-muted/50 rounded-2xl p-5 space-y-4 text-left">
<p className="text-xs text-muted-foreground/70 uppercase tracking-wider">
Built on three principles
</p>
{features.map((feature) => (
<div key={feature.title} className="space-y-1">
<h2 className="text-sm font-medium text-foreground">
{feature.title}
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
))}
</div>
{/* CTA Button */}
<Button size="lg" onClick={onStart} className="px-8">
Start Exploring
</Button>
</div>
</div>
)
}

View file

@ -1,195 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
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 { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon, Loading03Icon } from '@hugeicons/core-free-icons'
import { useChannels } from '../../hooks/use-channels'
import { TutorialStep } from '../../components/onboarding/tutorial-step'
import { useOnboardingStore } from '../../stores/onboarding'
function statusVariant(
status: string
): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'running':
return 'default'
case 'starting':
return 'secondary'
case 'error':
return 'destructive'
default:
return 'outline'
}
}
export default function ConnectStep() {
const navigate = useNavigate()
const { states, config, saveToken, loading: channelLoading } = useChannels()
const { setClientConnected } = useOnboardingStore()
const [token, setToken] = useState('')
const [saving, setSaving] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const state = states.find(
(s) => s.channelId === 'telegram' && s.accountId === 'default'
)
const savedConfig = config['telegram']?.['default'] as
| { botToken?: string }
| undefined
const hasToken = Boolean(savedConfig?.botToken)
const isRunning = state?.status === 'running'
const isStarting = state?.status === 'starting'
const handleConnect = async () => {
if (!token.trim()) return
setSaving(true)
setLocalError(null)
const result = await saveToken('telegram', 'default', token.trim())
if (!result.ok) {
setLocalError(result.error ?? 'Failed to connect')
} else {
setToken('')
setClientConnected(true)
}
setSaving(false)
}
const handleContinue = () => navigate('/onboarding/try-it')
const handleBack = () => navigate('/onboarding/setup')
return (
<div className="h-full flex">
{/* Left column */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<button
onClick={handleBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Back
</button>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Connect a client
</h1>
<p className="text-sm text-muted-foreground">
Connect a Telegram bot so you can chat with your agent from
anywhere. You can always set this up later in settings.
</p>
</div>
{channelLoading ? (
<div className="h-24 rounded-xl border border-border bg-card animate-pulse" />
) : hasToken ? (
<div className="p-4 rounded-xl border border-primary/30 bg-card space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">Telegram Bot</p>
{state && (
<Badge variant={statusVariant(state.status)}>
{state.status}
</Badge>
)}
</div>
{state?.status === 'error' && state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
{isRunning && (
<p className="text-sm text-muted-foreground">
Your bot is running. Send it a message on Telegram to test.
</p>
)}
{isStarting && (
<p className="text-sm text-muted-foreground">
Starting your bot...
</p>
)}
</div>
) : (
<div className="space-y-3">
<Input
type="password"
placeholder="Paste your bot token from @BotFather"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleConnect()}
/>
{localError && (
<p className="text-sm text-destructive">{localError}</p>
)}
<Button
size="sm"
onClick={handleConnect}
disabled={saving || !token.trim()}
>
{saving && (
<HugeiconsIcon
icon={Loading03Icon}
className="size-4 animate-spin mr-2"
/>
)}
Connect
</Button>
</div>
)}
<div className="flex justify-end gap-2">
{!hasToken && (
<Button size="lg" variant="ghost" onClick={handleContinue}>
Skip
</Button>
)}
<Button size="lg" onClick={handleContinue} disabled={!isRunning}>
Continue
</Button>
</div>
</div>
</div>
{/* Right column — BotFather tutorial */}
<div className="flex-1 flex items-center justify-center bg-muted/30 px-12 py-8">
<div className="max-w-sm space-y-6">
<div className="space-y-2">
<h3 className="text-lg font-medium">Create a Telegram bot</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Follow these steps to create your bot:
</p>
</div>
<div className="space-y-4">
<TutorialStep
number={1}
text="Open Telegram and search for @BotFather"
/>
<TutorialStep
number={2}
text="Send /newbot and follow the prompts to name your bot"
/>
<TutorialStep
number={3}
text="BotFather will give you a token like 123456:ABC-DEF..."
/>
<TutorialStep
number={4}
text='Paste the token on the left and click "Connect"'
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
Why connect Telegram?
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Once connected, you can chat with your Multica agent directly
from Telegram on any device phone, tablet, or desktop.
</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,89 @@
import { useNavigate } from "react-router-dom";
import { useOnboardingStore } from "../../stores/onboarding";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { ModeToggle } from "../../components/mode-toggle";
import WelcomeStep from "./components/welcome-step";
import PermissionsStep from "./components/permissions-step";
import SetupStep from "./components/setup-step";
import ConnectStep from "./components/connect-step";
import TryItStep from "./components/try-it-step";
const steps = ["Privacy", "Provider", "Channels", "Start"];
export default function OnboardingPage() {
const navigate = useNavigate();
const { currentStep, nextStep, prevStep, completeOnboarding } =
useOnboardingStore();
const handleComplete = async () => {
await completeOnboarding();
navigate("/");
};
// Welcome step (step 0) has no header content, just draggable area
if (currentStep === 0) {
return (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS - same height as main header */}
<header
className="shrink-0 h-12"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<main
key={currentStep}
className="flex-1 overflow-auto"
>
<WelcomeStep onStart={nextStep} />
</main>
</div>
);
}
const stepLabel = steps[currentStep - 1];
const totalSteps = steps.length;
return (
<div className="h-dvh flex flex-col bg-background">
<header className="shrink-0 h-12 flex items-center pr-4">
{/* Left: Draggable area for traffic lights */}
<div
className="w-20 h-full shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
{/* Brand */}
<div className="flex items-center gap-2 shrink-0">
<MulticaIcon bordered noSpin />
<span className="text-sm tracking-wide font-brand">Multica</span>
</div>
{/* Center: Step indicator */}
<div className="flex-1 flex justify-center">
<span className="text-sm text-muted-foreground">
{stepLabel} ({currentStep}/{totalSteps})
</span>
</div>
{/* Right: Theme toggle */}
<div className="shrink-0">
<ModeToggle />
</div>
</header>
{/* Step content */}
<main
key={currentStep}
className="flex-1 overflow-auto"
>
{currentStep === 1 && <PermissionsStep onNext={nextStep} />}
{currentStep === 2 && <SetupStep onNext={nextStep} onBack={prevStep} />}
{currentStep === 3 && (
<ConnectStep onNext={nextStep} onBack={prevStep} />
)}
{currentStep === 4 && (
<TryItStep onComplete={handleComplete} onBack={prevStep} />
)}
</main>
</div>
);
}

View file

@ -1,32 +0,0 @@
import { Outlet, useLocation } from 'react-router-dom'
import { Stepper, type StepId } from '../../components/onboarding/stepper'
export default function OnboardingLayout() {
const location = useLocation()
// Derive current step from URL path
const pathSegment = location.pathname.split('/').pop() as string
const validSteps: StepId[] = ['permissions', 'setup', 'connect', 'try-it']
const currentStep: StepId = validSteps.includes(pathSegment as StepId)
? (pathSegment as StepId)
: 'permissions'
return (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS + stepper */}
<header
className="shrink-0 px-6 pt-3 pb-2"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* Spacer for traffic lights */}
<div className="h-5" />
<Stepper currentStep={currentStep} />
</header>
{/* Step content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}

View file

@ -1,103 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import {
FolderOpenIcon,
CommandLineIcon,
AiBrainIcon,
Database01Icon,
} from '@hugeicons/core-free-icons'
import { AcknowledgementItem } from '../../components/onboarding/permission-item'
import { PrivacyPanel } from '../../components/onboarding/privacy-panel'
import { useOnboardingStore } from '../../stores/onboarding'
const acknowledgementItems = [
{
key: 'fileSystem' as const,
icon: FolderOpenIcon,
title: 'File system access',
description:
'Multica reads and writes files on your machine to complete tasks you assign.',
},
{
key: 'shellExecution' as const,
icon: CommandLineIcon,
title: 'Shell command execution',
description:
'The agent may run shell commands. Every command requires your explicit approval.',
},
{
key: 'llmRequests' as const,
icon: AiBrainIcon,
title: 'LLM API requests',
description:
'Multica sends prompts to your configured LLM provider. Your API key is used directly.',
},
{
key: 'localStorage' as const,
icon: Database01Icon,
title: 'Local data storage',
description:
'Sessions, profiles, and credentials are stored locally in ~/.super-multica/',
},
]
export default function PermissionsStep() {
const navigate = useNavigate()
const { acknowledgements, allAcknowledged, setAcknowledgement } =
useOnboardingStore()
const handleContinue = () => {
navigate('/onboarding/setup')
}
return (
<div className="h-full flex">
{/* Left column — main content, centered both axes */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Privacy & trust
</h1>
<p className="text-sm text-muted-foreground">
Multica works locally on your machine. Review what it accesses
and toggle each item to acknowledge.
</p>
</div>
<div className="space-y-3">
{acknowledgementItems.map((item) => (
<AcknowledgementItem
key={item.key}
icon={item.icon}
title={item.title}
description={item.description}
checked={acknowledgements[item.key]}
onCheckedChange={(checked) =>
setAcknowledgement(item.key, checked)
}
/>
))}
</div>
<div className="flex justify-end">
<Button
size="lg"
onClick={handleContinue}
disabled={!allAcknowledged}
>
Continue
</Button>
</div>
</div>
</div>
{/* Right column — privacy panel */}
<div className="flex-1 flex items-center justify-center bg-muted/30 px-12 py-8">
<div className="max-w-sm">
<PrivacyPanel />
</div>
</div>
</div>
)
}

View file

@ -1,223 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon } from '@hugeicons/core-free-icons'
import { useProvider } from '../../hooks/use-provider'
import { ApiKeyDialog } from '../../components/api-key-dialog'
import { OAuthDialog } from '../../components/oauth-dialog'
import { ProviderSetup } from '../../components/onboarding/provider-setup'
import { TutorialStep } from '../../components/onboarding/tutorial-step'
import { useOnboardingStore } from '../../stores/onboarding'
export default function SetupStep() {
const navigate = useNavigate()
const { providers, current, loading, error, refresh, setProvider } =
useProvider()
const { setProviderConfigured } = useOnboardingStore()
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false)
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const [selectedProvider, setSelectedProvider] =
useState<ProviderStatus | null>(null)
const [focusedProvider, setFocusedProvider] =
useState<ProviderStatus | null>(null)
const hasActiveProvider = current?.available === true
const handleConfigure = (provider: ProviderStatus) => {
setSelectedProvider(provider)
if (provider.authMethod === 'oauth') {
setOauthDialogOpen(true)
} else {
setApiKeyDialogOpen(true)
}
}
const handleSelect = async (provider: ProviderStatus) => {
await setProvider(provider.id)
}
const handleProviderSuccess = async (modelId?: string) => {
await refresh()
if (selectedProvider) {
await setProvider(selectedProvider.id, modelId)
setFocusedProvider(selectedProvider)
}
setProviderConfigured(true)
}
const handleContinue = () => {
navigate('/onboarding/connect')
}
const handleBack = () => {
navigate('/onboarding')
}
return (
<div className="h-full flex">
{/* Left column — main content, centered both axes */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<button
onClick={handleBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Back
</button>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Connect an LLM provider
</h1>
<p className="text-sm text-muted-foreground">
Multica needs at least one LLM provider to power your AI agent.
Add your API key below.
</p>
</div>
<ProviderSetup
providers={providers}
loading={loading}
activeProviderId={current?.available ? current.provider : undefined}
onConfigure={handleConfigure}
onSelect={handleSelect}
onFocus={setFocusedProvider}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex justify-end">
<Button
size="lg"
onClick={handleContinue}
disabled={!hasActiveProvider}
>
Continue
</Button>
</div>
</div>
</div>
{/* Right column — provider tutorial */}
<div className="flex-1 flex items-center justify-center bg-muted/30 px-12 py-8">
<div className="max-w-sm space-y-6">
{focusedProvider ? (
<ProviderTutorial provider={focusedProvider} />
) : (
<DefaultProviderInfo />
)}
</div>
</div>
{/* Dialogs */}
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
<ApiKeyDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
showModelInput={selectedProvider.id === 'openrouter'}
onSuccess={handleProviderSuccess}
/>
)}
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
<OAuthDialog
open={oauthDialogOpen}
onOpenChange={setOauthDialogOpen}
providerId={selectedProvider.id}
providerName={selectedProvider.name}
loginCommand={selectedProvider.loginCommand}
onSuccess={handleProviderSuccess}
/>
)}
</div>
)
}
function ProviderTutorial({ provider }: { provider: ProviderStatus }) {
return (
<div className="space-y-5">
<div className="space-y-2">
<h3 className="text-lg font-medium">Set up {provider.name}</h3>
<p className="text-sm text-muted-foreground">
Follow these steps to get started:
</p>
</div>
<div className="space-y-4">
{provider.authMethod === 'api-key' ? (
<>
<TutorialStep
number={1}
text={`Go to ${provider.loginUrl ?? 'the provider dashboard'}`}
link={provider.loginUrl}
/>
<TutorialStep number={2} text="Create a new API key" />
<TutorialStep
number={3}
text='Click "Configure" and paste your key'
/>
</>
) : (
<>
<TutorialStep
number={1}
text={`Open terminal and run: ${provider.loginCommand}`}
/>
<TutorialStep
number={2}
text="Complete login in your browser"
/>
<TutorialStep
number={3}
text='Click "Configure" then "Refresh"'
/>
</>
)}
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
API keys stay local
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Your API keys are stored securely in{' '}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
~/.super-multica/credentials.json5
</code>{' '}
and never leave your device.
</p>
</div>
</div>
)
}
function DefaultProviderInfo() {
return (
<div className="space-y-6">
<div className="space-y-3">
<h3 className="text-lg font-medium">Supported providers</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Multica supports multiple LLM providers including OpenAI,
Anthropic, DeepSeek, and more. You can configure additional
providers later in settings.
</p>
</div>
<div className="space-y-3">
<h3 className="text-lg font-medium">API keys stay local</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Your API keys are stored securely in{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
~/.super-multica/credentials.json5
</code>{' '}
and never leave your device.
</p>
</div>
</div>
)
}

View file

@ -1,128 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@multica/ui/components/ui/button'
import { Loading } from '@multica/ui/components/ui/loading'
import { ChatView } from '@multica/ui/components/chat-view'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon } from '@hugeicons/core-free-icons'
import { SamplePrompt } from '../../components/onboarding/sample-prompt'
import { useOnboardingStore } from '../../stores/onboarding'
import { useLocalChat } from '../../hooks/use-local-chat'
const samplePrompts = [
{
title: 'Latest AI news',
prompt:
"Search the web for today's top AI news and give me a 3-bullet summary with sources.",
},
{
title: 'Analyze this project',
prompt:
'Look at the files in my current directory and give me a brief summary of what this project is about.',
},
{
title: 'Quick task',
prompt:
'Write a one-liner shell command that shows my system info (OS, CPU cores, memory) and run it.',
},
]
export default function TryItStep() {
const navigate = useNavigate()
const { completeOnboarding } = useOnboardingStore()
const {
agentId,
initError,
messages,
streamingIds,
isLoading,
isLoadingHistory,
isLoadingMore,
hasMore,
error,
pendingApprovals,
sendMessage,
loadMore,
resolveApproval,
} = useLocalChat()
const handleComplete = () => {
completeOnboarding()
navigate('/')
}
const handleBack = () => {
navigate('/onboarding/connect')
}
return (
<div className="h-full flex">
{/* Left column — prompts */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<button
onClick={handleBack}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Back
</button>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Try it out
</h1>
<p className="text-sm text-muted-foreground">
Your agent can search the web, read files, and run commands.
Click a prompt to see it in action.
</p>
</div>
<div className="space-y-2">
{samplePrompts.map((sp) => (
<SamplePrompt
key={sp.title}
title={sp.title}
prompt={sp.prompt}
onClick={() => sendMessage(sp.prompt)}
/>
))}
</div>
<div className="flex justify-end">
<Button size="lg" onClick={handleComplete}>
Open Multica
</Button>
</div>
</div>
</div>
{/* Right column — live chat */}
<div className="flex-1 flex flex-col min-h-0 border-l">
{initError ? (
<div className="flex-1 flex items-center justify-center text-sm text-destructive px-8 text-center">
{initError}
</div>
) : !agentId ? (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />
Initializing agent...
</div>
) : (
<ChatView
messages={messages}
streamingIds={streamingIds}
isLoading={isLoading}
isLoadingHistory={isLoadingHistory}
isLoadingMore={isLoadingMore}
hasMore={hasMore}
error={error}
pendingApprovals={pendingApprovals}
sendMessage={sendMessage}
loadMore={loadMore}
resolveApproval={resolveApproval}
/>
)}
</div>
</div>
)
}

View file

@ -1,42 +1,31 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@multica/ui/components/ui/card'
import { useSkills } from '../hooks/use-skills'
import { useSkillsStore } from '../stores/skills'
import { SkillList } from '../components/skill-list'
export default function SkillsPage() {
const {
skills,
loading,
error,
toggleSkill,
refresh,
} = useSkills()
const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
return (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Skills</CardTitle>
<CardDescription>
Manage agent skills. Skills provide specialized capabilities like Git integration,
code review, and file manipulation. Toggle skills on/off to control agent behavior.
</CardDescription>
</CardHeader>
<CardContent>
<SkillList
skills={skills}
loading={loading}
error={error}
onToggleSkill={toggleSkill}
onRefresh={refresh}
/>
</CardContent>
</Card>
<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,44 +1,31 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@multica/ui/components/ui/card'
import { useTools } from '../hooks/use-tools'
import { useToolsStore } from '../stores/tools'
import { ToolList } from '../components/tool-list'
export default function ToolsPage() {
const {
tools,
groups,
loading,
error,
toggleTool,
refresh,
} = useTools()
const { tools, loading, error, toggleTool, refresh } = useToolsStore()
return (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Tools</CardTitle>
<CardDescription>
Configure which tools are available to the Agent. Toggle individual tools on/off.
Changes apply immediately to the running Agent.
</CardDescription>
</CardHeader>
<CardContent>
<ToolList
tools={tools}
groups={groups}
loading={loading}
error={error}
onToggleTool={toggleTool}
onRefresh={refresh}
/>
</CardContent>
</Card>
<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

@ -0,0 +1,180 @@
import { create } from 'zustand'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 800
interface ChannelsStore {
// State
states: ChannelAccountStateInfo[]
config: Record<string, Record<string, Record<string, unknown>> | undefined>
loading: boolean
error: string | null
initialized: boolean
// Actions
fetch: () => Promise<void>
refresh: (options?: { silent?: boolean }) => Promise<void>
saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }>
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
}
export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
states: [],
config: {},
loading: false,
error: null,
initialized: false,
fetch: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
const [stateList, channelConfig] = await Promise.all([
window.electronAPI.channels.listStates(),
window.electronAPI.channels.getConfig(),
])
set({
states: stateList,
config: channelConfig,
initialized: true,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[ChannelsStore] Failed to load:', message)
} finally {
set({ loading: false })
}
},
refresh: async (options?: { silent?: boolean }) => {
set({ loading: true, error: null })
const startTime = Date.now()
try {
const [stateList, channelConfig] = await Promise.all([
window.electronAPI.channels.listStates(),
window.electronAPI.channels.getConfig(),
])
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
set({
states: stateList,
config: channelConfig,
})
if (!options?.silent) toast.success('Channels refreshed')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to refresh channels', { description: message })
console.error('[ChannelsStore] Failed to refresh:', message)
} finally {
set({ loading: false })
}
},
saveToken: async (channelId: string, accountId: string, token: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.saveToken(channelId, accountId, token)
if (result.ok) {
await get().refresh()
toast.success('Channel connected')
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to save token' })
toast.error('Failed to connect channel', { description: result.error })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to connect channel', { description: message })
return { ok: false, error: message }
}
},
removeToken: async (channelId: string, accountId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.removeToken(channelId, accountId)
if (result.ok) {
await get().refresh()
toast.success('Channel removed')
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to remove token' })
toast.error('Failed to remove channel', { description: result.error })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to remove channel', { description: message })
return { ok: false, error: message }
}
},
stopChannel: async (channelId: string, accountId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.stop(channelId, accountId)
if (result.ok) {
await get().refresh()
toast.success('Channel stopped')
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to stop channel' })
toast.error('Failed to stop channel', { description: result.error })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to stop channel', { description: message })
return { ok: false, error: message }
}
},
startChannel: async (channelId: string, accountId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.start(channelId, accountId)
if (result.ok) {
await get().refresh()
toast.success('Channel started')
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to start channel' })
toast.error('Failed to start channel', { description: result.error })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to start channel', { description: message })
return { ok: false, error: message }
}
},
}))

View file

@ -1,13 +0,0 @@
import { create } from "zustand"
export type ChatMode = "select" | "local" | "remote"
interface ChatModeStore {
mode: ChatMode
setMode: (mode: ChatMode) => void
}
export const useChatModeStore = create<ChatModeStore>((set) => ({
mode: "select",
setMode: (mode) => set({ mode }),
}))

View file

@ -0,0 +1,163 @@
import { create } from 'zustand'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 800
// Types matching the IPC response
export interface CronJobInfo {
id: string
name: string
description?: string
enabled: boolean
schedule: string
sessionTarget: string
nextRunAt: string | null
lastStatus: 'ok' | 'error' | 'skipped' | null
lastRunAt: string | null
lastDurationMs: number | null
lastError: string | null
}
interface CronJobsStore {
// State
jobs: CronJobInfo[]
loading: boolean
error: string | null
initialized: boolean
// Actions
fetch: () => Promise<void>
refresh: (options?: { silent?: boolean }) => Promise<void>
toggleJob: (jobId: string) => Promise<void>
removeJob: (jobId: string) => Promise<void>
}
export const useCronJobsStore = create<CronJobsStore>()((set, get) => ({
jobs: [],
loading: false,
error: null,
initialized: false,
fetch: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
const result = await window.electronAPI.cron.list()
if (Array.isArray(result)) {
set({
jobs: result as CronJobInfo[],
initialized: true,
})
} else {
set({ error: 'Invalid response from cron:list' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[CronJobsStore] Failed to load:', message)
} finally {
set({ loading: false })
}
},
refresh: async (options?: { silent?: boolean }) => {
set({ loading: true, error: null })
const startTime = Date.now()
try {
const result = await window.electronAPI.cron.list()
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
if (Array.isArray(result)) {
set({ jobs: result as CronJobInfo[] })
if (!options?.silent) toast.success('Tasks refreshed')
} else {
set({ error: 'Invalid response from cron:list' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to refresh tasks', { description: message })
console.error('[CronJobsStore] Failed to refresh:', message)
} finally {
set({ loading: false })
}
},
toggleJob: async (jobId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.cron.toggle(jobId)
const typedResult = result as { error?: string; id?: string; enabled?: boolean }
if (typedResult.error) {
set({ error: typedResult.error })
toast.error('Failed to toggle task', { description: typedResult.error })
return
}
// Find job name for toast
const job = get().jobs.find(j => j.id === jobId)
const jobName = job?.name ?? jobId
// Update local state
set((state) => ({
jobs: state.jobs.map((job) =>
job.id === jobId
? { ...job, enabled: typedResult.enabled ?? !job.enabled }
: job
),
}))
toast.success(`${jobName} ${typedResult.enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to toggle task', { description: message })
console.error('[CronJobsStore] Failed to toggle:', message)
}
},
removeJob: async (jobId: string) => {
set({ error: null })
try {
// Find job name before removing
const job = get().jobs.find(j => j.id === jobId)
const jobName = job?.name ?? jobId
const result = await window.electronAPI.cron.remove(jobId)
const typedResult = result as { error?: string; ok?: boolean }
if (typedResult.error) {
set({ error: typedResult.error })
toast.error('Failed to remove task', { description: typedResult.error })
return
}
// Update local state
set((state) => ({
jobs: state.jobs.filter((job) => job.id !== jobId),
}))
toast.success(`${jobName} removed`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to remove task', { description: message })
console.error('[CronJobsStore] Failed to remove:', message)
}
},
}))

View file

@ -0,0 +1,112 @@
import { create } from 'zustand'
// Connection state types
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'registered'
export interface HubInfo {
hubId: string
url: string
connectionState: ConnectionState
agentCount: number
}
export interface AgentInfo {
id: string
closed: boolean
}
interface HubStore {
// State
hubInfo: HubInfo | null
agents: AgentInfo[]
loading: boolean
error: string | null
initialized: boolean
// Actions
init: () => Promise<void>
refresh: () => Promise<void>
reconnect: (url: string) => Promise<{ ok: boolean; error?: string }>
}
export const useHubStore = create<HubStore>()((set, get) => ({
hubInfo: null,
agents: [],
loading: false,
error: null,
initialized: false,
init: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
await window.electronAPI.hub.init()
const info = await window.electronAPI.hub.info()
const agentList = await window.electronAPI.hub.listAgents()
set({
hubInfo: info as HubInfo,
agents: agentList as AgentInfo[],
initialized: true,
})
// Subscribe to connection state changes
window.electronAPI.hub.onConnectionStateChanged((state: string) => {
set((prev) => ({
hubInfo: prev.hubInfo
? { ...prev.hubInfo, connectionState: state as ConnectionState }
: prev.hubInfo,
}))
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[HubStore] Failed to initialize:', message)
} finally {
set({ loading: false })
}
},
refresh: async () => {
set({ error: null })
try {
const info = await window.electronAPI.hub.info()
const agentList = await window.electronAPI.hub.listAgents()
set({
hubInfo: info as HubInfo,
agents: agentList as AgentInfo[],
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[HubStore] Failed to refresh:', message)
}
},
reconnect: async (url: string) => {
set({ error: null })
try {
await window.electronAPI.hub.reconnect(url)
await get().refresh()
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return { ok: false, error: message }
}
},
}))
// Selector helpers
export const selectPrimaryAgent = (agents: AgentInfo[]) => agents[0] ?? null
export const selectIsConnected = (hubInfo: HubInfo | null) => {
if (!hubInfo) return false
return hubInfo.connectionState === 'connected' || hubInfo.connectionState === 'registered'
}

View file

@ -1,5 +1,4 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface AcknowledgementsState {
fileSystem: boolean
@ -9,57 +8,60 @@ interface AcknowledgementsState {
}
interface OnboardingStore {
// Persisted state (loaded from file system via IPC)
completed: boolean
forceOnboarding: boolean
// Transient state (reset on page reload)
currentStep: number
acknowledgements: AcknowledgementsState
allAcknowledged: boolean
providerConfigured: boolean
clientConnected: boolean
// Actions
setCompleted: (completed: boolean) => void
setStep: (step: number) => void
nextStep: () => void
prevStep: () => void
setAcknowledgement: (key: keyof AcknowledgementsState, value: boolean) => void
setProviderConfigured: (configured: boolean) => void
setClientConnected: (connected: boolean) => void
completeOnboarding: () => void
initForceFlag: () => Promise<void>
completeOnboarding: () => Promise<void>
}
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set, get) => ({
completed: false,
forceOnboarding: false,
export const useOnboardingStore = create<OnboardingStore>()((set, get) => ({
// Initial state - will be hydrated from file system on app start
completed: false,
currentStep: 0,
acknowledgements: {
fileSystem: false,
shellExecution: false,
llmRequests: false,
localStorage: false,
},
allAcknowledged: false,
providerConfigured: false,
clientConnected: false,
acknowledgements: {
fileSystem: false,
shellExecution: false,
llmRequests: false,
localStorage: false,
},
allAcknowledged: false,
providerConfigured: false,
clientConnected: false,
setAcknowledgement: (key, value) => {
const acknowledgements = { ...get().acknowledgements, [key]: value }
const allAcknowledged = Object.values(acknowledgements).every(Boolean)
set({ acknowledgements, allAcknowledged })
},
setCompleted: (completed) => set({ completed }),
setProviderConfigured: (configured) => set({ providerConfigured: configured }),
setStep: (step) => set({ currentStep: step }),
nextStep: () => set({ currentStep: Math.min(get().currentStep + 1, 4) }),
prevStep: () => set({ currentStep: Math.max(get().currentStep - 1, 0) }),
setClientConnected: (connected) => set({ clientConnected: connected }),
setAcknowledgement: (key, value) => {
const acknowledgements = { ...get().acknowledgements, [key]: value }
const allAcknowledged = Object.values(acknowledgements).every(Boolean)
set({ acknowledgements, allAcknowledged })
},
completeOnboarding: () => set({ completed: true, forceOnboarding: false }),
setProviderConfigured: (configured) => set({ providerConfigured: configured }),
initForceFlag: async () => {
const flags = await window.electronAPI.app.getFlags()
if (flags.forceOnboarding) {
set({ forceOnboarding: true })
}
},
}),
{
name: 'multica-onboarding',
partialize: (state) => ({ completed: state.completed }),
}
)
)
setClientConnected: (connected) => set({ clientConnected: connected }),
completeOnboarding: async () => {
// Persist to file system via IPC
await window.electronAPI.appState.setOnboardingCompleted(true)
// Update local state
set({ completed: true, currentStep: 0 })
},
}))

View file

@ -0,0 +1,128 @@
import { create } from 'zustand'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 800
interface ProviderStore {
// State
providers: ProviderStatus[]
current: CurrentProviderInfo | null
loading: boolean
error: string | null
initialized: boolean
// Actions
fetch: () => Promise<void>
setProvider: (providerId: string, modelId?: string, options?: { silent?: boolean }) => Promise<{ ok: boolean; error?: string }>
refresh: () => Promise<void>
}
export const useProviderStore = create<ProviderStore>()((set, get) => ({
providers: [],
current: null,
loading: false,
error: null,
initialized: false,
fetch: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
const [providerList, currentInfo] = await Promise.all([
window.electronAPI.provider.list(),
window.electronAPI.provider.current(),
])
set({
providers: providerList,
current: currentInfo,
initialized: true,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[ProviderStore] Failed to load providers:', message)
} finally {
set({ loading: false })
}
},
refresh: async () => {
set({ loading: true, error: null })
const startTime = Date.now()
try {
const [providerList, currentInfo] = await Promise.all([
window.electronAPI.provider.list(),
window.electronAPI.provider.current(),
])
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
set({
providers: providerList,
current: currentInfo,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to refresh providers', { description: message })
console.error('[ProviderStore] Failed to refresh providers:', message)
} finally {
set({ loading: false })
}
},
setProvider: async (providerId: string, modelId?: string, options?: { silent?: boolean }) => {
set({ error: null })
try {
const result = await window.electronAPI.provider.set(providerId, modelId)
if (result.ok) {
// Quick refresh without minimum delay for setProvider
const [providerList, currentInfo] = await Promise.all([
window.electronAPI.provider.list(),
window.electronAPI.provider.current(),
])
set({ providers: providerList, current: currentInfo })
// Find provider name for toast
if (!options?.silent) {
const provider = providerList.find(p => p.id === providerId)
toast.success(`Switched to ${provider?.name ?? providerId}`)
}
return { ok: true }
} else {
set({ error: result.error ?? 'Unknown error' })
if (!options?.silent) {
toast.error('Failed to switch provider', { description: result.error })
}
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
if (!options?.silent) {
toast.error('Failed to switch provider', { description: message })
}
return { ok: false, error: message }
}
},
}))
// Selector helpers
export const selectAvailableProviders = (providers: ProviderStatus[]) =>
providers.filter(p => p.available)
export const selectProviderById = (providers: ProviderStatus[], id: string) =>
providers.find(p => p.id === id)

View file

@ -0,0 +1,176 @@
import { create } from 'zustand'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 800
// Types matching the IPC response
export type SkillSource = 'bundled' | 'global' | 'profile'
export interface SkillInfo {
id: string
name: string
description: string
version: string
enabled: boolean
source: SkillSource
triggers: string[]
}
interface SkillsStore {
// State
skills: SkillInfo[]
loading: boolean
error: string | null
initialized: boolean
// Actions
fetch: () => Promise<void>
refresh: (options?: { silent?: boolean }) => Promise<void>
toggleSkill: (skillId: string) => Promise<void>
setSkillStatus: (skillId: string, enabled: boolean) => Promise<void>
}
export const useSkillsStore = create<SkillsStore>()((set, get) => ({
skills: [],
loading: false,
error: null,
initialized: false,
fetch: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
const result = await window.electronAPI.skills.list()
if (Array.isArray(result)) {
set({
skills: result,
initialized: true,
})
} else {
set({ error: 'Invalid response from skills:list' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[SkillsStore] Failed to load:', message)
} finally {
set({ loading: false })
}
},
refresh: async (options?: { silent?: boolean }) => {
set({ loading: true, error: null })
const startTime = Date.now()
try {
const result = await window.electronAPI.skills.list()
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
if (Array.isArray(result)) {
set({ skills: result })
if (!options?.silent) toast.success('Skills refreshed')
} else {
set({ error: 'Invalid response from skills:list' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to refresh skills', { description: message })
console.error('[SkillsStore] Failed to refresh:', message)
} finally {
set({ loading: false })
}
},
toggleSkill: async (skillId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.skills.toggle(skillId)
const typedResult = result as { error?: string; enabled?: boolean }
if (typedResult.error) {
set({ error: typedResult.error })
toast.error('Failed to toggle skill', { description: typedResult.error })
return
}
// Find skill name for toast
const skill = get().skills.find(s => s.id === skillId)
const skillName = skill?.name ?? skillId
// Update local state
set((state) => ({
skills: state.skills.map((skill) =>
skill.id === skillId
? { ...skill, enabled: typedResult.enabled ?? !skill.enabled }
: skill
),
}))
toast.success(`${skillName} ${typedResult.enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to toggle skill', { description: message })
console.error('[SkillsStore] Failed to toggle:', message)
}
},
setSkillStatus: async (skillId: string, enabled: boolean) => {
set({ error: null })
try {
const result = await window.electronAPI.skills.setStatus(skillId, enabled)
const typedResult = result as { error?: string }
if (typedResult.error) {
set({ error: typedResult.error })
toast.error('Failed to update skill', { description: typedResult.error })
return
}
// Find skill name for toast
const skill = get().skills.find(s => s.id === skillId)
const skillName = skill?.name ?? skillId
// Update local state
set((state) => ({
skills: state.skills.map((skill) =>
skill.id === skillId ? { ...skill, enabled } : skill
),
}))
toast.success(`${skillName} ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to update skill', { description: message })
console.error('[SkillsStore] Failed to set status:', message)
}
},
}))
// Selector helpers (use with useMemo in components)
export const selectEnabledSkills = (skills: SkillInfo[]) =>
skills.filter(s => s.enabled)
export const selectSkillStats = (skills: SkillInfo[]) => ({
total: skills.length,
enabled: skills.filter(s => s.enabled).length,
disabled: skills.filter(s => !s.enabled).length,
bundled: skills.filter(s => s.source === 'bundled').length,
global: skills.filter(s => s.source === 'global').length,
profile: skills.filter(s => s.source === 'profile').length,
})

View file

@ -0,0 +1,184 @@
import { create } from 'zustand'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 800
// Types matching the IPC response
export interface ToolInfo {
name: string
description?: string
group: string
enabled: boolean
}
// Tool descriptions (for UI display)
const TOOL_DESCRIPTIONS: Record<string, string> = {
read: 'Read file contents',
write: 'Write content to file',
edit: 'Edit file with search/replace',
glob: 'Find files by pattern',
exec: 'Execute shell commands',
process: 'Manage background processes',
web_fetch: 'Fetch content from URLs',
web_search: 'Search the web via Devv Search',
memory_get: 'Get stored memory value',
memory_set: 'Store a memory value',
memory_delete: 'Delete a memory value',
memory_list: 'List all memory keys',
memory_search: 'Search memory files for keywords',
cron: 'Create and manage scheduled tasks',
}
interface ToolsStore {
// State
tools: ToolInfo[]
loading: boolean
error: string | null
initialized: boolean
// Actions
fetch: () => Promise<void>
refresh: (options?: { silent?: boolean }) => Promise<void>
toggleTool: (toolName: string) => Promise<void>
setToolStatus: (toolName: string, enabled: boolean) => Promise<void>
}
export const useToolsStore = create<ToolsStore>()((set, get) => ({
tools: [],
loading: false,
error: null,
initialized: false,
fetch: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
const result = await window.electronAPI.tools.list()
if (Array.isArray(result)) {
// Add descriptions to tools
const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({
...tool,
description: TOOL_DESCRIPTIONS[tool.name],
}))
set({
tools: toolsWithDesc,
initialized: true,
})
} else {
set({ error: 'Invalid response from tools:list' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[ToolsStore] Failed to load:', message)
} finally {
set({ loading: false })
}
},
refresh: async (options?: { silent?: boolean }) => {
set({ loading: true, error: null })
const startTime = Date.now()
try {
const result = await window.electronAPI.tools.list()
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
if (Array.isArray(result)) {
const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({
...tool,
description: TOOL_DESCRIPTIONS[tool.name],
}))
set({ tools: toolsWithDesc })
if (!options?.silent) toast.success('Tools refreshed')
} else {
set({ error: 'Invalid response from tools:list' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to refresh tools', { description: message })
console.error('[ToolsStore] Failed to refresh:', message)
} finally {
set({ loading: false })
}
},
toggleTool: async (toolName: string) => {
set({ error: null })
try {
const result = await window.electronAPI.tools.toggle(toolName)
const typedResult = result as { error?: string; enabled?: boolean }
if (typedResult.error) {
set({ error: typedResult.error })
toast.error('Failed to toggle tool', { description: typedResult.error })
return
}
// Update local state
set((state) => ({
tools: state.tools.map((tool) =>
tool.name === toolName
? { ...tool, enabled: typedResult.enabled ?? !tool.enabled }
: tool
),
}))
toast.success(`${toolName} ${typedResult.enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to toggle tool', { description: message })
console.error('[ToolsStore] Failed to toggle:', message)
}
},
setToolStatus: async (toolName: string, enabled: boolean) => {
set({ error: null })
try {
const result = await window.electronAPI.tools.setStatus(toolName, enabled)
const typedResult = result as { error?: string }
if (typedResult.error) {
set({ error: typedResult.error })
toast.error('Failed to update tool', { description: typedResult.error })
return
}
// Update local state
set((state) => ({
tools: state.tools.map((tool) =>
tool.name === toolName ? { ...tool, enabled } : tool
),
}))
toast.success(`${toolName} ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to update tool', { description: message })
console.error('[ToolsStore] Failed to set status:', message)
}
},
}))
// Selector helpers
export const selectEnabledTools = (tools: ToolInfo[]) =>
tools.filter(t => t.enabled)
export const selectEnabledToolsCount = (tools: ToolInfo[]) =>
tools.filter(t => t.enabled).length

View file

@ -1,79 +1,44 @@
# Build stage
FROM node:22-alpine AS builder
# Install pnpm
# Stage 1: install dependencies (cached layer)
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy package files (pnpm-workspace.yaml needed for catalog resolution)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Copy all workspace package.json files (pnpm --frozen-lockfile needs the full workspace structure)
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/cli/package.json ./apps/cli/
COPY apps/desktop/package.json ./apps/desktop/
COPY apps/gateway/package.json ./apps/gateway/
COPY apps/mobile/package.json ./apps/mobile/
COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/
COPY packages/core/package.json ./packages/core/
COPY packages/hooks/package.json ./packages/hooks/
COPY packages/sdk/package.json ./packages/sdk/
COPY packages/store/package.json ./packages/store/
COPY packages/types/package.json ./packages/types/
COPY packages/ui/package.json ./packages/ui/
COPY packages/utils/package.json ./packages/utils/
# Install all dependencies (including devDependencies for build)
RUN pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile --filter @multica/gateway...
# Copy workspace packages and bundle them with esbuild (resolves extensionless imports)
# Stage 2: runtime
FROM node:22-alpine
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy pnpm structure + node_modules (includes hoisted deps and workspace symlinks)
COPY --from=deps /app ./
# Copy workspace packages needed at runtime (raw .ts source, resolved by tsx)
COPY packages/sdk/ ./packages/sdk/
COPY packages/store/ ./packages/store/
RUN ./node_modules/.bin/esbuild packages/sdk/src/index.ts \
--bundle --platform=node --format=esm --outfile=packages/sdk/dist/index.js --packages=external && \
./node_modules/.bin/esbuild packages/store/src/index.ts packages/store/src/connection.ts \
--bundle --splitting --platform=node --format=esm --outdir=packages/store/dist --packages=external
# Copy source code
COPY tsconfig.json ./
COPY src ./src
# Copy gateway source + static assets
COPY apps/gateway/ ./apps/gateway/
# Build TypeScript (tsc emits JS despite type errors; ignore exit code)
RUN ./node_modules/.bin/tsc || true
# Production stage
FROM node:22-alpine AS production
# Install pnpm
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy package files (pnpm-workspace.yaml needed for catalog resolution)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/sdk/package.json ./packages/sdk/
COPY packages/store/package.json ./packages/store/
# Install production dependencies only
RUN pnpm install --frozen-lockfile --prod
# Copy built workspace packages and patch exports to point to compiled JS
COPY --from=builder /app/packages/sdk/dist ./packages/sdk/dist
COPY --from=builder /app/packages/store/dist ./packages/store/dist
RUN node -e " \
const fs = require('fs'); \
['packages/sdk/package.json', 'packages/store/package.json'].forEach(p => { \
const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); \
if (pkg.exports) { \
for (const [key, val] of Object.entries(pkg.exports)) { \
if (typeof val === 'string') { \
pkg.exports[key] = val.replace('./src/', './dist/').replace('.ts', '.js'); \
} \
} \
} \
fs.writeFileSync(p, JSON.stringify(pkg, null, 2)); \
});"
# Copy built files from builder stage
COPY --from=builder /app/dist ./dist
# Copy static assets (not emitted by tsc)
COPY --from=builder /app/src/gateway/public ./dist/gateway/public
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Expose the port
EXPOSE 3000
# Run the gateway
CMD ["node", "dist/gateway/main.js"]
WORKDIR /app/apps/gateway
CMD ["node", "--import", "tsx", "main.ts"]

View file

@ -10,6 +10,7 @@ REPO="085931705009.dkr.ecr.us-west-2.amazonaws.com/super-multica/gateway"
BRANCH="$(git symbolic-ref --short -q HEAD | tr '/' '-')"
IMAGE_TAG="$(date +%F_%H-%M-%S)-${BRANCH}-$(git rev-parse --short HEAD)"
IMAGE="$REPO:$IMAGE_TAG"
IMAGE_LATEST="$REPO:latest"
# Determine if sudo is needed for docker commands
if [[ "$(uname -s)" == "Linux" ]]; then
@ -21,13 +22,27 @@ fi
echo "Building image: $IMAGE"
echo "Using Dockerfile: $GATEWAY_DIR/Dockerfile"
echo "Build context: $PROJECT_ROOT"
echo ""
# Login to ECR
aws ecr get-login-password --region us-west-2 | $DOCKER_CMD login --username AWS --password-stdin 085931705009.dkr.ecr.us-west-2.amazonaws.com
# Build from project root with gateway Dockerfile
$DOCKER_CMD build -f "$GATEWAY_DIR/Dockerfile" -t "$IMAGE" "$PROJECT_ROOT"
START_TIME=$(date +%s)
$DOCKER_CMD build \
-f "$GATEWAY_DIR/Dockerfile" \
-t "$IMAGE" \
-t "$IMAGE_LATEST" \
"$PROJECT_ROOT"
END_TIME=$(date +%s)
echo ""
echo "Build completed in $((END_TIME - START_TIME))s"
# Push both tags
$DOCKER_CMD push "$IMAGE"
$DOCKER_CMD push "$IMAGE_LATEST"
echo ""
echo "Successfully pushed: $IMAGE"
echo "Successfully pushed:"
echo " $IMAGE"
echo " $IMAGE_LATEST"

View file

@ -53,8 +53,10 @@ const VERIFY_TIMEOUT_MS = 30_000;
@Injectable()
export class TelegramService implements OnModuleInit {
private static readonly TYPING_TIMEOUT_MS = 60_000; // 1 minute safety cap
private bot: Bot | null = null;
private pendingRequests = new Map<string, PendingRequest>();
private typingTimers = new Map<string, ReturnType<typeof setInterval>>();
private readonly logger = new Logger(TelegramService.name);
@ -351,12 +353,21 @@ export class TelegramService implements OnModuleInit {
return;
}
// Stream event — extract text content for Telegram
// Stream event — typing indicator + extract text content for Telegram
if (msg.action === StreamAction) {
const streamPayload = msg.payload as StreamPayload;
const event = streamPayload?.event;
if (event && "type" in event && event.type === "message_end") {
// Extract final text from the message
if (!event || !("type" in event)) return;
// Start typing when LLM begins generating
if (event.type === "message_start") {
this.startTyping(telegramUserId);
return;
}
// Stop typing + send text on message_end
if (event.type === "message_end") {
this.stopTyping(telegramUserId);
const agentMsg = (event as { message?: { content?: Array<{ type: string; text?: string }> } }).message;
if (agentMsg?.content) {
const textContent = agentMsg.content
@ -367,7 +378,15 @@ export class TelegramService implements OnModuleInit {
this.sendToTelegram(deviceId, textContent);
}
}
return;
}
// Stop typing on error
if (event.type === "agent_error") {
this.stopTyping(telegramUserId);
return;
}
return;
}
@ -382,6 +401,7 @@ export class TelegramService implements OnModuleInit {
// Error messages
if (msg.action === "error") {
this.stopTyping(telegramUserId);
const payload = msg.payload as { message?: string; code?: string };
if (payload?.message) {
this.sendToTelegram(deviceId, `Error: ${payload.message}`);
@ -391,6 +411,34 @@ export class TelegramService implements OnModuleInit {
});
}
/** Start sending "typing" indicator to Telegram at regular intervals */
private startTyping(telegramUserId: string): void {
if (this.typingTimers.has(telegramUserId)) return;
const chatId = Number(telegramUserId);
const send = () => {
void this.bot?.api.sendChatAction(chatId, "typing").catch(() => {});
};
send();
const interval = setInterval(send, 5000);
this.typingTimers.set(telegramUserId, interval);
// Safety timeout: auto-stop if no message_end/agent_error arrives
setTimeout(() => {
if (this.typingTimers.get(telegramUserId) === interval) {
this.stopTyping(telegramUserId);
}
}, TelegramService.TYPING_TIMEOUT_MS);
}
/** Stop the "typing" indicator for a Telegram user */
private stopTyping(telegramUserId: string): void {
const timer = this.typingTimers.get(telegramUserId);
if (timer) {
clearInterval(timer);
this.typingTimers.delete(telegramUserId);
}
}
/** Cleanup all pending requests (used on verify failure) */
private cleanupPendingRequests(): void {
for (const [id, pending] of this.pendingRequests) {

View file

@ -11,8 +11,7 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Text } from "@/components/ui/text";
import { HugeiconsIcon } from "@hugeicons/react-native";
import { ArrowUp01Icon } from "@hugeicons/core-free-icons";
import { ArrowUp } from "lucide-react-native";
// Enable LayoutAnimation on Android
if (Platform.OS === "android" && UIManager.setLayoutAnimationEnabledExperimental) {
@ -152,11 +151,9 @@ export default function Index() {
className="ml-1.5 h-8 w-8 items-center justify-center rounded-full bg-primary"
style={{ opacity: canSend ? 1 : 0.5 }}
>
<HugeiconsIcon
icon={ArrowUp01Icon}
<ArrowUp
size={18}
color="hsl(225, 100%, 96.4%)"
strokeWidth={2.5}
/>
</Pressable>
</View>

View file

@ -12,8 +12,6 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react-native": "^1.0.11",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",

View file

@ -7,7 +7,7 @@ export function Header() {
<header className="container flex justify-between items-center p-2">
<div className="flex items-center gap-2.5">
<img src="/logo.svg" alt="Multica" className="size-6 rounded-md" />
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
<span className="text-sm tracking-wide font-brand">
Multica
</span>
</div>

View file

@ -1,28 +1,10 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter, Playfair_Display } from "next/font/google";
import "@multica/ui/fonts";
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 geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
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",
@ -42,10 +24,8 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${playfair.variable} antialiased h-dvh flex flex-col`}
>
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased h-dvh flex flex-col">
<ThemeProvider
attribute="class"
defaultTheme="system"

View file

@ -2,8 +2,7 @@
import { useTheme } from "next-themes";
import { Button } from "@multica/ui/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sun01Icon, Moon01Icon } from "@hugeicons/core-free-icons";
import { Sun, Moon } from "lucide-react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@ -15,8 +14,8 @@ export function ThemeToggle() {
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="size-8 text-muted-foreground"
>
<HugeiconsIcon icon={Sun01Icon} strokeWidth={1.5} className="size-4 dark:hidden" />
<HugeiconsIcon icon={Moon01Icon} strokeWidth={1.5} className="size-4 hidden dark:block" />
<Sun className="size-4 dark:hidden" />
<Moon className="size-4 hidden dark:block" />
</Button>
);
}

View file

@ -9,7 +9,7 @@
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "hugeicons",
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",

View file

@ -13,14 +13,12 @@
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
"uuid": "catalog:",
"zustand": "catalog:",
"@hugeicons/core-free-icons": "catalog:",
"@hugeicons/react": "catalog:",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"uuid": "catalog:",
"zustand": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",

View file

@ -1,42 +1,124 @@
# Super Multica Product Capabilities
> This document is the single source of truth for all product capabilities. It describes **what exists**, not how to design or how to use it. All subsequent documents (user journeys, UI design, copywriting, design systems) should reference this document.
> This document is the single source of truth for all product capabilities. It describes **what exists** and **what value it provides to users**. All subsequent documents (user journeys, UI design, copywriting, design systems) should reference this document.
---
## 1. Product Definition
**Super Multica** is a distributed AI Agent framework. Users can create, customize, and deploy AI Agents with persistent memory, fine-grained capability control, and multi-provider LLM support. Agents run locally on the user's machine; remote access is optional.
**Super Multica** is your personal AI Agent that runs entirely on your machine. It can read files, execute commands, search the web, and remember everything you tell it—all while keeping your data local and under your control.
> **Core narrative**: "A personal AI Agent running on your computer. Your data, your API keys, your control."
**Product Focus (Current Version)**:
- **Primary**: Personal use — one user, one agent, local-first
- **Secondary**: Remote access via phone/web (optional, for power users)
**Core architecture**:
```
Desktop App (standalone, recommended)
└─ Hub (embedded, manages agents)
Desktop App (standalone, primary)
└─ Hub (embedded, manages agent)
└─ Agent Engine (LLM execution, sessions, skills, tools)
└─ (Optional) Gateway connection → remote clients (web/mobile)
└─ (Optional) Gateway → remote access (web/mobile)
```
---
## 2. User Roles
## 2. Core Value Propositions
| Role | Definition | Platform | Authority |
|------|-----------|----------|-----------|
| **Owner** | Runs the Desktop app, owns Hub and Agents | Desktop (Electron) | Full: create/delete agents, approve devices, configure providers, manage profiles/skills |
| **Collaborator** | Connects to Owner's Agent via Gateway | Web / Mobile | Limited: chat with agent, view message history. No agent management. |
> **Design Reference**: This section summarizes key value propositions for UI/UX design, copywriting, and user communication decisions. Use this as a quick reference when deciding what to emphasize in interfaces and messaging.
There is no formal role/permission system. The Owner is implicit admin by virtue of running the Hub.
### 2.1 Primary Differentiators
These are the core values that distinguish Super Multica. Communicate them in order of priority.
**Tier 1 — Core Identity (Always communicate)**
| Value | User-Facing Message | Technical Basis |
|-------|---------------------|-----------------|
| **Personal AI Agent** | "Your own AI assistant, running on your machine" | Desktop-first, embedded Hub, single-user focus |
| **Capable** | "Read files, run commands, search the web, remember things" | 12 built-in tools across file/runtime/web/memory |
| **Local-First** | "Your data never leaves your computer" | All data stored in `~/.super-multica/`, no cloud dependency |
**Tier 2 — Trust & Control (Reinforce throughout)**
| Value | User-Facing Message | Technical Basis |
|-------|---------------------|-----------------|
| **Your Keys, Your Control** | "Use your own API keys, switch models anytime" | 10 LLM providers, user-owned credentials |
| **Safe Execution** | "Every command requires your approval" | 4-layer security assessment + approval protocol |
| **Persistent Memory** | "Your agent remembers what you tell it" | Profile system + Memory tools |
**Tier 3 — Power Features (Available, not prominent)**
| Value | User-Facing Message | Technical Basis |
|-------|---------------------|-----------------|
| **Extensible Skills** | "Teach your agent new abilities" | Modular skill system with hot-reload |
| **Remote Access** | "Access from phone or web when needed" | Gateway + Device pairing (optional) |
### 2.2 Trust-Building Points
These messages should be reinforced throughout the user journey, especially during onboarding and when requesting sensitive permissions.
| Context | What User Worries About | How We Address It |
|---------|------------------------|-------------------|
| First launch | "Will this access my files?" | Explicit permission acknowledgment with clear scope |
| API key entry | "Where is my key stored?" | "Stored locally in `~/.super-multica/credentials.json5`. Never sent to our servers." |
| Command execution | "What if it runs something dangerous?" | 4-layer safety check + mandatory user approval + allow-once/allow-always options |
| Memory storage | "What does it remember about me?" | User-controlled, file-based, inspectable at `~/.super-multica/agent-profiles/` |
| Remote access | "Who can access my agent?" | Device whitelist with explicit approval, one-time tokens, 30s expiry |
### 2.3 Feature Priority Matrix
Use this when designing interfaces to determine information hierarchy and feature prominence.
| Priority | Features | Where to Expose | Design Guidance |
|----------|----------|-----------------|-----------------|
| **P0 - Always Visible** | Chat, Agent status (running/idle), Approval dialogs | Main UI, always accessible | Cannot be hidden or collapsed |
| **P1 - Dashboard** | Current provider/model, Available tools, Session info | Home/Dashboard, 1 click away | Show what agent can do |
| **P2 - Configuration** | Provider selection, Profile/Skills management, Tool policy | Settings or sidebar sections | Available but not prominent |
| **P3 - Power Features** | Remote access (QR/Gateway), Device management, Memory inspection | Settings → Advanced | Hidden from casual users |
| **P4 - Developer** | CLI commands, Session JSONL format, Gateway config | Documentation only | Not exposed in UI |
**Key Change**: QR code and remote access moved from P0 to P3. Dashboard (agent capabilities) moved up to P1.
### 2.4 Messaging Tone Guidelines
| Situation | Tone | Example |
|-----------|------|---------|
| Explaining privacy | Reassuring, factual | "All data stays on your machine. We can't access it even if we wanted to." |
| Requesting permission | Clear, non-alarming | "Multica needs to read files to help you. You control which files." |
| Command approval | Cautious but not scary | "Review this command before running." (not "DANGER: This could harm your system!") |
| Error states | Helpful, actionable | "API key invalid. Check your key in Settings → Providers." |
| Success states | Brief, confident | "Connected." (not "Successfully connected to the server!") |
---
## 3. Functional Modules
## 3. User Model
### 3.1 Agent Engine
**Current Version**: Single-user, personal agent.
The core execution unit. An Agent receives user messages, calls an LLM, executes tools, and returns responses.
| Role | Definition | Platform |
|------|-----------|----------|
| **You** | The person running the Desktop app | Desktop (Electron) |
#### 3.1.1 Agent Lifecycle
You own your agent. You control what it can do, which LLM it uses, and what it remembers.
**Future Consideration**: Remote access allows you to chat with your agent from other devices (phone, web). This is an optional power feature, not the primary use case.
**User-Facing Value**: "Your personal AI agent. Runs on your machine, works for you."
---
## 4. Functional Modules
### 4.1 Agent Engine
> **User-Facing Value**: "Your personal AI that lives on your computer. It can read your files, run commands, search the web, and remember everything you tell it."
The core execution unit. Your Agent receives messages, calls an LLM, executes tools, and returns responses.
#### 4.1.1 Agent Lifecycle
| State | Description |
|-------|-------------|
@ -47,7 +129,7 @@ The core execution unit. An Agent receives user messages, calls an LLM, executes
Each `write()` call is queued. Messages are processed sequentially (one at a time).
#### 3.1.2 Agent Execution Loop
#### 4.1.2 Agent Execution Loop
1. Receive user message via `write(content)`
2. Resolve API credentials (with auth profile rotation)
@ -58,7 +140,7 @@ Each `write()` call is queued. Messages are processed sequentially (one at a tim
7. Check context window utilization → compact if needed
8. Emit events to subscribers (streaming to UI)
#### 3.1.3 Auth Profile Rotation
#### 4.1.3 Auth Profile Rotation
When an API call fails, the system classifies the error and may rotate to a different API key:
@ -75,7 +157,7 @@ Failed profiles enter cooldown. Rotation continues until success or all profiles
Tracking file: `~/.super-multica/.auth-profiles/usage-stats.json`
#### 3.1.4 Subagent Spawning
#### 4.1.4 Subagent Spawning
Agents can spawn child agents via the `sessions_spawn` tool:
@ -85,7 +167,7 @@ Agents can spawn child agents via the `sessions_spawn` tool:
- Parameters: task (required), label, model override, cleanup policy (`delete` or `keep`), timeout
- Results announced back to parent automatically
#### 3.1.5 Agent Configuration Options
#### 4.1.5 Agent Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
@ -99,7 +181,9 @@ Agents can spawn child agents via the `sessions_spawn` tool:
---
### 3.2 LLM Providers
### 4.2 LLM Providers
> **User-Facing Value**: "Use your own API keys. Switch between Claude, GPT, Gemini, and more. Your keys, your choice."
Ten providers supported. Two auth methods: OAuth (CLI login) and API Key.
@ -126,11 +210,13 @@ Ten providers supported. Two auth methods: OAuth (CLI login) and API Key.
---
### 3.3 Tools
### 4.3 Tools
> **User-Facing Value**: "Your agent can read files, write code, run commands, search the web, and remember things for you."
Tools are capabilities the Agent can invoke during execution.
#### 3.3.1 Built-in Tools
#### 4.3.1 Built-in Tools
| Tool | Category | Description |
|------|----------|-------------|
@ -148,7 +234,7 @@ Tools are capabilities the Agent can invoke during execution.
| `memory_delete` | Memory | Delete memory entries |
| `sessions_spawn` | Subagent | Spawn a child agent for a specific task |
#### 3.3.2 Tool Groups (shortcuts for policy)
#### 4.3.2 Tool Groups (shortcuts for policy)
| Group | Tools Included |
|-------|---------------|
@ -159,7 +245,7 @@ Tools are capabilities the Agent can invoke during execution.
| `group:subagent` | sessions_spawn |
| `group:core` | read, write, edit, glob, exec, process, web_search, web_fetch |
#### 3.3.3 Tool Policy System (3 layers)
#### 4.3.3 Tool Policy System (3 layers)
| Layer | Scope | Description |
|-------|-------|-------------|
@ -169,13 +255,63 @@ Tools are capabilities the Agent can invoke during execution.
**Priority**: Deny always overrides Allow. Empty allow list = deny all.
#### 3.3.4 Exec Tool Details
#### 4.3.4 Exec Tool Details
- Default yield timeout: 10,000ms (auto-backgrounds if not complete)
- Supports `timeoutMs` for hard kill (SIGTERM)
- Output includes: stdout+stderr, exitCode, truncation flag, process ID if backgrounded
#### 3.3.5 Web Search Details
#### 4.3.5 Exec Approval Protocol
> **User-Facing Value**: "Every shell command requires your explicit approval. You're always in control."
The exec tool implements a 4-layer security assessment before any command execution:
| Layer | Check | Examples | Result if Failed |
|-------|-------|----------|------------------|
| 1. Whitelist | Glob pattern match against allowed commands | `git *`, `npm install` | Skip to next layer |
| 2. Shell Syntax | Dangerous shell constructs detection | `$(...)`, backticks, pipes to dangerous commands | `dangerous` |
| 3. Safe Binaries | ~40 known-safe read-only commands | `ls`, `cat`, `grep`, `git status` | `safe` if matched |
| 4. Danger Patterns | 25+ regex patterns for risky operations | `rm -rf`, `chmod 777`, `curl | sh` | `dangerous` if matched |
**Risk Levels**:
| Level | Meaning | User Action Required |
|-------|---------|---------------------|
| `safe` | Read-only or whitelisted command | Auto-approved (configurable) |
| `needs-review` | Unknown command, not obviously dangerous | User must approve |
| `dangerous` | Matches danger pattern or shell injection risk | User must approve with warning |
**User Approval Options**:
| Option | Effect | Persistence |
|--------|--------|-------------|
| Allow Once | Execute this command only | This execution only |
| Allow Always | Add to session whitelist | Until session ends |
| Deny | Block execution | This execution only |
**Approval Flow**:
```
Agent requests exec("npm install express")
4-layer security check → "needs-review"
Desktop shows approval dialog:
"The agent wants to run: npm install express"
[Allow Once] [Allow Always] [Deny]
User clicks "Allow Once"
Command executes, result returned to agent
```
**Design Guidance**: This is a core trust-building feature. The approval dialog should be:
- Clear and non-alarming for safe commands
- Appropriately cautious for dangerous commands
- Never auto-dismissed or timed out (user must act)
#### 4.3.6 Web Search Details
- Brave provider: up to 10 results, country filtering, freshness filters (`pd`/`pw`/`pm`/`py`)
- Perplexity provider: AI-synthesized answers
@ -183,11 +319,13 @@ Tools are capabilities the Agent can invoke during execution.
---
### 3.4 Profile System
### 4.4 Profile System
> **User-Facing Value**: "Give your agent a personality. Define how it talks, what it knows, and how it works."
A Profile defines an Agent's identity, personality, knowledge, and configuration.
#### 3.4.1 Profile File Structure
#### 4.4.1 Profile File Structure
```
~/.super-multica/agent-profiles/{profileId}/
@ -204,7 +342,7 @@ A Profile defines an Agent's identity, personality, knowledge, and configuration
└── SKILL.md
```
#### 3.4.2 Profile Config (config.json)
#### 4.4.2 Profile Config (config.json)
```json
{
@ -220,7 +358,7 @@ A Profile defines an Agent's identity, personality, knowledge, and configuration
}
```
#### 3.4.3 Profile Operations
#### 4.4.3 Profile Operations
| Operation | CLI | Desktop |
|-----------|-----|---------|
@ -233,7 +371,7 @@ A Profile defines an Agent's identity, personality, knowledge, and configuration
**Profile ID rules**: alphanumeric, hyphens, underscores only.
#### 3.4.4 System Prompt Composition
#### 4.4.4 System Prompt Composition
The system prompt is built dynamically from profile files:
@ -253,11 +391,13 @@ The system prompt is built dynamically from profile files:
---
### 3.5 Memory System
### 4.5 Memory System
> **User-Facing Value**: "Your agent remembers what you tell it. Preferences, facts, context—it's all there next time."
Agents can persistently store and recall information across sessions.
#### 3.5.1 Storage
#### 4.5.1 Storage
- Location: `~/.super-multica/agent-profiles/{profileId}/memory/`
- Format: One JSON file per key
@ -265,7 +405,7 @@ Agents can persistently store and recall information across sessions.
- Dots in keys are escaped as `__DOT__` in filenames
- Max value size: 1MB
#### 3.5.2 Entry Format
#### 4.5.2 Entry Format
```json
{
@ -276,7 +416,7 @@ Agents can persistently store and recall information across sessions.
}
```
#### 3.5.3 Memory Tools
#### 4.5.3 Memory Tools
| Tool | Input | Output |
|------|-------|--------|
@ -289,11 +429,13 @@ Agents can persistently store and recall information across sessions.
---
### 3.6 Skills System
### 4.6 Skills System
> **User-Facing Value**: "Teach your agent new tricks. Install skills for Git, code review, and more."
Skills are modular, self-contained capabilities defined via `SKILL.md` files. They extend what an Agent can do.
#### 3.6.1 Skill File Format (SKILL.md)
#### 4.6.1 Skill File Format (SKILL.md)
```yaml
---
@ -314,7 +456,7 @@ metadata:
# Full markdown instructions follow...
```
#### 3.6.2 Skill Sources & Precedence
#### 4.6.2 Skill Sources & Precedence
| Source | Location | Precedence |
|--------|----------|-----------|
@ -324,7 +466,7 @@ metadata:
Profile skills with the same ID completely replace global/bundled versions.
#### 3.6.3 Bundled Skills
#### 4.6.3 Bundled Skills
| Skill | ID | Description | Requirements |
|-------|----|-------------|-------------|
@ -333,7 +475,7 @@ Profile skills with the same ID completely replace global/bundled versions.
| Profile Setup | `profile-setup` | Interactive wizard to personalize agent profile | None |
| Skill Creator | `skill-creator` | Create, edit, manage custom skills | None (always eligible) |
#### 3.6.4 Eligibility Check Sequence
#### 4.6.4 Eligibility Check Sequence
1. Explicit disable in config → ineligible
2. Bundled + not in allowlist → ineligible
@ -347,13 +489,13 @@ Profile skills with the same ID completely replace global/bundled versions.
Returns human-readable failure reasons (e.g., "Required binary not found: git").
#### 3.6.5 Skill Invocation
#### 4.6.5 Skill Invocation
- **User invocation**: `/skillname args` in interactive CLI
- **Model invocation**: Agent reads skill instructions from system prompt and follows them
- **Hot reload**: File watcher detects SKILL.md changes, reloads automatically (250ms debounce)
#### 3.6.6 Skill Installation
#### 4.6.6 Skill Installation
```bash
multica skills add owner/repo # Clone entire repository
@ -364,18 +506,20 @@ multica skills add owner/repo -p my-agent # Install to profile
---
### 3.7 Session Management
### 4.7 Session Management
> **User-Facing Value**: "Pick up where you left off. Your conversation history is always saved."
Sessions persist conversation history across interactions.
#### 3.7.1 Session Storage
#### 4.7.1 Session Storage
- Location: `~/.super-multica/sessions/{sessionId}/session.jsonl`
- Format: JSON Lines (one JSON object per line)
- Session IDs: UUIDv7 (time-ordered)
- Each line is either a message entry, meta entry, or compaction entry
#### 3.7.2 Message Format
#### 4.7.2 Message Format
Messages follow the LLM API format:
@ -385,13 +529,13 @@ Messages follow the LLM API format:
{"type": "message", "role": "user", "content": [{"type": "tool_result", "tool_use_id": "...", "content": "file contents"}]}
```
#### 3.7.3 Session Metadata
#### 4.7.3 Session Metadata
```json
{"type": "meta", "provider": "anthropic", "model": "claude-sonnet-4-5", "reasoningMode": "off", "contextWindowTokens": 200000}
```
#### 3.7.4 Context Window Management
#### 4.7.4 Context Window Management
| Parameter | Value | Description |
|-----------|-------|-------------|
@ -404,7 +548,7 @@ Messages follow the LLM API format:
| Min keep messages | 10 | Never remove below this |
| Reserve tokens | 1,024 | Reserved for response generation |
#### 3.7.5 Compaction Modes
#### 4.7.5 Compaction Modes
| Mode | Strategy | Speed | Quality |
|------|----------|-------|---------|
@ -412,7 +556,7 @@ Messages follow the LLM API format:
| `count` | Remove oldest when count > 80, keep last 60 | Fastest | Adequate |
| `summary` | LLM generates incremental summary of removed messages | Slow (API call) | Best (preserves meaning) |
#### 3.7.6 Session Operations
#### 4.7.6 Session Operations
| Operation | CLI Command |
|-----------|-------------|
@ -423,11 +567,11 @@ Messages follow the LLM API format:
---
### 3.8 Hub
### 4.8 Hub
The Hub is the central coordinator. It manages agent lifecycle, routes messages, and handles device verification.
The Hub manages your agent. It runs embedded in the Desktop app—you don't need to think about it.
#### 3.8.1 Responsibilities
#### 4.8.1 Responsibilities
- Create, list, restore, close agents
- Persist agent metadata to disk (`~/.super-multica/agents/agents.json`)
@ -435,7 +579,7 @@ The Hub is the central coordinator. It manages agent lifecycle, routes messages,
- Handle device verification and whitelisting
- Process RPC requests from connected clients
#### 3.8.2 Hub RPC Methods
#### 4.8.2 Hub RPC Methods
| Method | Description | Error Codes |
|--------|-------------|-------------|
@ -447,27 +591,29 @@ The Hub is the central coordinator. It manages agent lifecycle, routes messages,
| `deleteAgent` | Delete agent | - |
| `updateGateway` | Update Gateway connection | - |
#### 3.8.3 Hub Singleton
#### 4.8.3 Hub Singleton
One Hub per ecosystem. In Desktop mode, it's embedded in the Electron main process. It generates a persistent Hub ID stored at `~/.super-multica/hub-id`.
---
### 3.9 Gateway
### 4.9 Gateway
NestJS WebSocket server that enables remote client access to the Hub.
NestJS WebSocket server that enables remote client access to your agent.
#### 3.9.1 Purpose
**Note**: Optional component. Most users don't need this for personal use.
Bridges remote clients (web/mobile) to the Hub. Not needed for local Desktop use.
#### 4.9.1 Purpose
#### 3.9.2 Connection Protocol
Bridges remote clients (web/mobile) to your agent. **Not needed for local Desktop use** — the primary use case.
#### 4.9.2 Connection Protocol
- Transport: Socket.io
- Path: `/ws`
- Port: 3000 (default)
#### 3.9.3 Timeouts
#### 4.9.3 Timeouts
| Parameter | Value |
|-----------|-------|
@ -477,13 +623,13 @@ Bridges remote clients (web/mobile) to the Hub. Not needed for local Desktop use
| Verify timeout | 30 seconds |
| Reconnect delay | 1 second |
#### 3.9.4 Message Routing
#### 4.9.4 Message Routing
- Each message has `from` (sender device ID) and `to` (target device ID)
- Gateway validates: sender is registered, `from` matches socket, target exists
- Supports streaming via `StreamAction` (message_start, message_update, message_end, tool events)
#### 3.9.5 Error Codes
#### 4.9.5 Error Codes
| Code | Meaning |
|------|---------|
@ -493,11 +639,15 @@ Bridges remote clients (web/mobile) to the Hub. Not needed for local Desktop use
---
### 3.10 Device Pairing & Verification
### 4.10 Device Pairing & Verification
How remote devices (web/mobile) connect to the Owner's Hub.
> **User-Facing Value**: "Access your agent from your phone when you need it."
#### 3.10.1 QR Code Generation (Desktop)
**Note**: This is a power feature for users who want remote access. Not part of core onboarding or primary UI.
How remote devices (web/mobile) connect to your agent.
#### 4.10.1 QR Code Generation (Desktop)
The Desktop app generates a QR code containing:
@ -517,7 +667,7 @@ The Desktop app generates a QR code containing:
- Auto-refresh: new token generated when expired
- Also available as URL: `multica://connect?gateway=...&hub=...&agent=...&token=...&exp=...`
#### 3.10.2 Connection Code Formats (accepted by client)
#### 4.10.2 Connection Code Formats (accepted by client)
| Format | Example |
|--------|---------|
@ -525,7 +675,7 @@ The Desktop app generates a QR code containing:
| Base64 JSON | Base64-encoded JSON string |
| URL | `multica://connect?gateway=...&hub=...&agent=...&token=...&exp=...` |
#### 3.10.3 Verification Flow
#### 4.10.3 Verification Flow
```
1. Mobile scans QR / pastes code
@ -542,7 +692,7 @@ The Desktop app generates a QR code containing:
9. If rejected: connection closed
```
#### 3.10.4 Device Whitelist
#### 4.10.4 Device Whitelist
- Location: `~/.super-multica/client-devices/whitelist.json`
- Format:
@ -563,17 +713,17 @@ The Desktop app generates a QR code containing:
}
```
#### 3.10.5 Reconnection (whitelisted device)
#### 4.10.5 Reconnection (whitelisted device)
Whitelisted devices reconnect without needing a new token or user confirmation. Hub checks `isAllowed(deviceId)` and returns immediately.
#### 3.10.6 Device Management (Desktop)
#### 4.10.6 Device Management (Desktop)
- View verified devices list with metadata
- Revoke individual devices (remove from whitelist)
- No fine-grained permissions (all-or-nothing access)
#### 3.10.7 Security Model
#### 4.10.7 Security Model
| Aspect | Detail |
|--------|--------|
@ -587,9 +737,9 @@ Whitelisted devices reconnect without needing a new token or user confirmation.
---
### 3.11 Credentials System
### 4.11 Credentials System
#### 3.11.1 Files
#### 4.11.1 Files
| File | Purpose | Permissions |
|------|---------|-------------|
@ -598,7 +748,7 @@ Whitelisted devices reconnect without needing a new token or user confirmation.
Format: JSON5 (supports comments, trailing commas, unquoted keys).
#### 3.11.2 credentials.json5 Structure
#### 4.11.2 credentials.json5 Structure
```json5
{
@ -621,7 +771,7 @@ Format: JSON5 (supports comments, trailing commas, unquoted keys).
}
```
#### 3.11.3 skills.env.json5 Structure
#### 4.11.3 skills.env.json5 Structure
```json5
{
@ -632,7 +782,7 @@ Format: JSON5 (supports comments, trailing commas, unquoted keys).
}
```
#### 3.11.4 Environment Variable Overrides
#### 4.11.4 Environment Variable Overrides
| Variable | Purpose |
|----------|---------|
@ -642,38 +792,125 @@ Format: JSON5 (supports comments, trailing commas, unquoted keys).
---
## 4. Platform Details
### 4.12 Channel Integration
### 4.1 Desktop App (Primary)
> **User-Facing Value**: "Chat with your agent from Telegram when you're away from your desk."
**Note**: Power feature for users who want additional access methods. Not part of core experience.
Channels enable external messaging platforms to communicate with the Agent. Currently supported: Telegram.
#### 4.12.1 Architecture
```
External Platform (Telegram)
Channel Adapter (grammy library)
Channel Manager (message routing)
Hub → Agent Engine
Response routed back via lastRoute
```
#### 4.12.2 Telegram Channel
| Feature | Description |
|---------|-------------|
| **Connection Mode** | Long polling (default) or Webhook |
| **Bot Setup** | User creates bot via @BotFather, provides token |
| **Message Handling** | Text messages forwarded to Agent |
| **Response Routing** | Replies sent back to same Telegram chat |
| **Message Chunking** | Long responses split into multiple messages (4096 char limit) |
**Setup Flow**:
1. User opens @BotFather in Telegram
2. Creates new bot with `/newbot`
3. Copies bot token (format: `123456789:ABCdefGHI...`)
4. Pastes token in Multica Desktop → Settings → Channels
5. Bot starts, ready to receive messages
**Status Indicators**:
| Status | Meaning | User Action |
|--------|---------|-------------|
| `starting` | Bot initializing | Wait |
| `running` | Bot active, receiving messages | None |
| `error` | Connection failed | Check token, retry |
| `stopped` | Bot disabled | Enable in settings |
#### 4.12.3 Message Path Routing
The system maintains a `lastRoute` to ensure responses go back through the correct channel:
| Message Source | Route | Response Destination |
|----------------|-------|---------------------|
| Desktop IPC | `local` | Desktop chat UI |
| Web/Mobile via Gateway | `gateway:{deviceId}` | Same device via Gateway |
| Telegram | `telegram:{chatId}` | Same Telegram chat |
**Design Note**: This routing is automatic. Users don't need to understand it, but it enables seamless multi-channel conversations.
#### 4.12.4 Future Extensibility
The channel architecture is designed to support additional platforms:
| Potential Channel | Status | Notes |
|-------------------|--------|-------|
| Discord | Planned | Similar bot model to Telegram |
| Slack | Planned | Workspace app integration |
| WhatsApp | Considered | Business API required |
| Email | Considered | IMAP/SMTP integration |
**Design Guidance**: When designing channel settings UI, use a consistent pattern:
- Platform icon + name
- Connection status indicator
- Setup/configure button
- Disconnect option
---
## 5. Platform Details
### 5.1 Desktop App (Primary)
**Technology**: Electron + Vite + React 19
**Window**: 1200x800, context isolation enabled, node integration disabled
#### 4.1.1 Pages
#### 5.1.1 Pages
| Route | Page | Purpose |
|-------|------|---------|
| `/` | Home | Hub status, QR code, provider selector, agent settings, device list |
| `/chat` | Chat | Message history, chat input, mode switcher (local/remote) |
| `/` | Dashboard | Agent status, current provider/model, quick actions |
| `/chat` | Chat | Message history, chat input |
| `/tools` | Tools | Tool listing and inspection |
| `/skills` | Skills | Skill listing and management |
| `/settings` | Settings | Provider config, remote access, advanced options |
**Navigation**: Tab bar at top (Home, Chat, Tools, Skills)
**Navigation**: Sidebar (planned) — Dashboard, Chat, Tools, Skills, Settings
#### 4.1.2 Home Page Components
**Dashboard Focus**: Show what your agent can do and its current state, not "how to connect devices".
| Component | Description |
|-----------|-------------|
| QR Code | Left side. Shows connection code with 30s countdown. Refresh/copy link buttons. |
| Hub Status | Right side. Hub ID, connection state indicator (green/yellow/red). |
| Agent Settings | Agent name (editable). |
| Provider Selector | Dropdown showing all providers with availability status. API Key dialog or OAuth dialog based on provider type. |
| Device List | Verified devices with name, platform, revoke button. |
| Open Chat | Button. Disabled if Hub not connected. |
| Connect to Remote Agent | Button. Navigate to remote agent connection. |
#### 5.1.2 Dashboard Components (Planned)
#### 4.1.3 Chat Page Modes
| Component | Description | Priority |
|-----------|-------------|----------|
| Agent Status | Running/Idle indicator, current task if any | P0 |
| Provider Info | Current provider + model, token usage if available | P1 |
| Quick Actions | "Start Chat", "New Session" | P1 |
| Capabilities Overview | Available tools and skills summary | P1 |
| Session History | Recent conversations, quick resume | P2 |
| Settings Access | Link to provider config, advanced settings | P2 |
**Moved to Settings → Remote Access**:
- QR Code for device pairing
- Connected devices list
- Gateway configuration
#### 5.1.3 Chat Page Modes
| Mode | Transport | When Used |
|------|-----------|-----------|
@ -682,7 +919,7 @@ Format: JSON5 (supports comments, trailing commas, unquoted keys).
Mode switcher available at top of chat page.
#### 4.1.4 Desktop IPC Channels
#### 5.1.4 Desktop IPC Channels
| Channel | Direction | Purpose |
|---------|-----------|---------|
@ -693,7 +930,7 @@ Mode switcher available at top of chat page.
---
### 4.2 Web App
### 5.2 Web App
**Technology**: Next.js 16 + App Router
@ -710,7 +947,7 @@ Mode switcher available at top of chat page.
---
### 4.3 Mobile App
### 5.3 Mobile App
**Technology**: Expo + React Native
@ -724,11 +961,11 @@ Mode switcher available at top of chat page.
---
### 4.4 CLI
### 5.4 CLI
**Entry point**: `multica` (alias: `mu`)
#### 4.4.1 Commands
#### 5.4.1 Commands
| Command | Description |
|---------|-------------|
@ -742,7 +979,7 @@ Mode switcher available at top of chat page.
| `multica credentials init/show/edit` | Credentials management |
| `multica dev [service]` | Development servers |
#### 4.4.2 Interactive Mode Commands
#### 5.4.2 Interactive Mode Commands
| Command | Description |
|---------|-------------|
@ -758,7 +995,7 @@ Mode switcher available at top of chat page.
**Features**: Autocomplete (Shift+Tab), status bar (session/provider/model), multi-line mode (end with `.`).
#### 4.4.3 Development Servers
#### 5.4.3 Development Servers
| Service | Command | Port |
|---------|---------|------|
@ -769,11 +1006,11 @@ Mode switcher available at top of chat page.
---
## 5. UI Component Library
## 6. UI Component Library
Shared package: `@multica/ui`. Used by Desktop, Web, and Mobile.
### 5.1 Chat Components
### 6.1 Chat Components
| Component | Props | Description |
|-----------|-------|-------------|
@ -785,7 +1022,7 @@ Shared package: `@multica/ui`. Used by Desktop, Web, and Mobile.
| `ChatSkeleton` | (none) | Loading skeleton |
| `ToolCallItem` | `message` | Tool execution display: status dot, label, subtitle, expandable results |
### 5.2 Markdown Components
### 6.2 Markdown Components
| Component | Props | Description |
|-----------|-------|-------------|
@ -793,11 +1030,11 @@ Shared package: `@multica/ui`. Used by Desktop, Web, and Mobile.
| `StreamingMarkdown` | `content`, `isStreaming`, `mode` | Incremental markdown with animated cursor |
| `CodeBlock` | (internal) | Syntax-highlighted code block with copy button |
### 5.3 Base UI Components (Shadcn/UI)
### 6.3 Base UI Components (Shadcn/UI)
button, input, textarea, card, dialog, alert-dialog, dropdown-menu, select, combobox, badge, label, field, input-group, switch, skeleton, separator, sheet, sidebar, tooltip, sonner (toasts)
### 5.4 Utility Components
### 6.4 Utility Components
| Component | Description |
|-----------|-------------|
@ -809,7 +1046,7 @@ button, input, textarea, card, dialog, alert-dialog, dropdown-menu, select, comb
---
## 6. Data Persistence Locations
## 7. Data Persistence Locations
| Data | Location | Format | Lifetime |
|------|----------|--------|----------|
@ -828,7 +1065,7 @@ button, input, textarea, card, dialog, alert-dialog, dropdown-menu, select, comb
---
## 7. Current Limitations
## 8. Current Limitations
| Area | Limitation | Notes |
|------|-----------|-------|
@ -843,5 +1080,8 @@ button, input, textarea, card, dialog, alert-dialog, dropdown-menu, select, comb
---
*Document generated: 2026-02-05*
*Source: codebase analysis at commit fc6c3e3 on branch feat/mobile-pwa-optimization*
*Document generated: 2026-02-11*
*Source: codebase analysis on branch feat/onboarding-check*
*Updates:*
- *Added Core Value Propositions (Section 2), Exec Approval Protocol (4.3.5), Channel Integration (4.12)*
- *2026-02-11: **Product positioning shift** — from "distributed framework" to "personal AI agent". Remote access demoted to power feature. Dashboard replaces connection-focused home page.*

View file

@ -13,6 +13,7 @@
"mu": "pnpm --filter @multica/cli dev",
"dev": "pnpm --filter @multica/desktop dev",
"dev:desktop": "pnpm --filter @multica/desktop dev",
"dev:desktop:onboarding": "pnpm --filter @multica/desktop dev:onboarding",
"dev:gateway": "pnpm --filter @multica/gateway dev",
"dev:web": "pnpm --filter @multica/web dev",
"dev:all": "concurrently \"pnpm dev:gateway\" \"pnpm dev:web\"",
@ -70,9 +71,10 @@
"croner": "^10.0.1",
"fast-glob": "^3.3.3",
"grammy": "^1.39.3",
"mysql2": "^3.14.1",
"json5": "^2.2.3",
"linkedom": "^0.18.12",
"lucide-react": "^0.563.0",
"mysql2": "^3.14.1",
"nestjs-pino": "^4.5.0",
"pino": "^10.3.0",
"pino-http": "^11.0.0",

View file

@ -0,0 +1,126 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { DATA_DIR } from "@multica/utils";
/**
* Application state stored in ~/.super-multica/app-state.json
*/
export interface AppState {
version?: number;
onboarding?: {
completed: boolean;
completedAt?: string;
};
}
const APP_STATE_PATH = join(DATA_DIR, "app-state.json");
/**
* Manages application-level state persisted to the file system.
* This is separate from credentials and agent profiles.
*/
export class AppStateManager {
private path: string = APP_STATE_PATH;
private state: AppState | null = null;
private mtimeMs: number | null = null;
/**
* Load state from file, using cache if file hasn't changed.
*/
private load(): AppState {
let mtimeMs: number | null = null;
if (existsSync(this.path)) {
try {
mtimeMs = statSync(this.path).mtimeMs;
} catch {
mtimeMs = null;
}
}
// Return cached state if file hasn't changed
if (this.state && this.mtimeMs === mtimeMs) {
return this.state;
}
this.mtimeMs = mtimeMs;
// File doesn't exist, return default state
if (mtimeMs === null) {
this.state = { version: 1 };
return this.state;
}
// Read and parse file
try {
const raw = readFileSync(this.path, "utf8");
this.state = JSON.parse(raw) as AppState;
} catch {
// If parse fails, return default state
this.state = { version: 1 };
}
return this.state;
}
/**
* Save state to file.
*/
private save(state: AppState): void {
mkdirSync(dirname(this.path), { recursive: true });
const content = JSON.stringify(state, null, 2);
writeFileSync(this.path, content, "utf8");
// Update cache
this.state = state;
try {
this.mtimeMs = statSync(this.path).mtimeMs;
} catch {
this.mtimeMs = null;
}
}
/**
* Check if onboarding has been completed.
*/
getOnboardingCompleted(): boolean {
const state = this.load();
return state.onboarding?.completed ?? false;
}
/**
* Mark onboarding as completed.
*/
setOnboardingCompleted(completed: boolean): void {
const state = this.load();
state.onboarding = {
completed,
completedAt: completed ? new Date().toISOString() : undefined,
};
this.save(state);
}
/**
* Reset the manager's cache, forcing a reload on next access.
*/
reset(): void {
this.state = null;
this.mtimeMs = null;
}
/**
* Reset onboarding state (for development testing).
* Sets completed to false and removes completedAt.
*/
resetOnboarding(): void {
const state = this.load();
state.onboarding = {
completed: false,
};
this.save(state);
}
}
export const appStateManager = new AppStateManager();

View file

@ -11,6 +11,7 @@ export * from './channels/index.js'
export * from './cron/index.js'
export * from './heartbeat/index.js'
export * from './media/index.js'
export * from './app-state.js'
// Client exports (selective to avoid conflicts with agent/events)
export {

View file

@ -9,7 +9,7 @@
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "hugeicons",
"iconLibrary": "lucide",
"aliases": {
"components": "@multica/ui/components",
"utils": "@multica/ui/lib/utils",

View file

@ -3,12 +3,16 @@
"version": "0.1.0",
"private": true,
"type": "module",
"sideEffects": ["**/*.css"],
"sideEffects": [
"**/*.css",
"./src/styles/fonts.ts"
],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src"
},
"exports": {
"./fonts": "./src/styles/fonts.ts",
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./lib/*": "./src/lib/*.ts",
@ -20,8 +24,9 @@
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"@hugeicons/core-free-icons": "catalog:",
"@hugeicons/react": "catalog:",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@multica/store": "workspace:*",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
@ -29,14 +34,18 @@
"@tiptap/starter-kit": "^3.19.0",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"katex": "^0.16.28",
"linkify-it": "^5.0.0",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"qr-scanner": "^1.4.2",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shadcn": "^3.7.0",
"shiki": "^3.21.0",
"sonner": "^2.0.7",

View file

@ -22,7 +22,7 @@ export function AppSidebar({ children }: AppSidebarProps) {
alt="Multica"
className="size-7 rounded-md"
/>
<span className="text-sm tracking-wide font-[family-name:var(--font-brand)]">
<span className="text-sm tracking-wide font-brand">
Multica
</span>
</div>

View file

@ -4,8 +4,7 @@ import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Button } from "@multica/ui/components/ui/button";
import { ArrowUp02Icon, StopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowUp, Square } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import "./chat-input.css";
@ -22,10 +21,12 @@ interface ChatInputProps {
isLoading?: boolean;
disabled?: boolean;
placeholder?: string;
/** Initial value to pre-fill the input */
defaultValue?: string;
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput({ onSubmit, onAbort, isLoading, disabled, placeholder = "Type a message..." }, ref) {
function ChatInput({ onSubmit, onAbort, isLoading, disabled, placeholder = "Type a message...", defaultValue }, ref) {
// Use ref to avoid stale closure in Tiptap keydown handler
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
@ -48,12 +49,20 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
}),
Placeholder.configure({ placeholder }),
],
content: defaultValue ? `<p>${defaultValue}</p>` : "",
immediatelyRender: false,
// Scroll cursor into view on every content change (e.g., Shift+Enter newlines)
onUpdate({ editor }) {
editor.commands.scrollIntoView();
},
editorProps: {
attributes: {
class:
"w-full resize-none bg-transparent px-1 py-1 text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
},
// Ensure cursor scrolls into view when typing near container edges
scrollThreshold: 20,
scrollMargin: 20,
handleKeyDown(_view, event) {
// Guard for IME composition (Chinese/Japanese input)
if (event.isComposing) return false;
@ -129,7 +138,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<EditorContent className="min-h-12" editor={editor} />
<div className="flex items-center justify-end pt-2">
<Button size="icon" onClick={handleButtonClick} disabled={disabled && !showStop}>
<HugeiconsIcon strokeWidth={2.5} icon={showStop ? StopIcon : ArrowUp02Icon} />
{showStop ? <Square className="size-4 fill-current" /> : <ArrowUp />}
</Button>
</div>
</div>

View file

@ -42,6 +42,8 @@ export interface ChatViewProps {
onDisconnect?: () => void;
/** Optional action button in the error banner (e.g. "Configure Provider") */
errorAction?: { label: string; onClick: () => void };
/** Initial prompt to pre-fill the input (e.g., from onboarding) */
initialPrompt?: string;
}
export function ChatView({
@ -59,6 +61,7 @@ export function ChatView({
resolveApproval,
onDisconnect,
errorAction,
initialPrompt,
}: ChatViewProps) {
const mainRef = useRef<HTMLElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
@ -263,6 +266,7 @@ export function ChatView({
isLoading={isLoading}
disabled={!!error && error.code !== 'AGENT_ERROR'}
placeholder={error && error.code !== 'AGENT_ERROR' ? "Connection error" : "Ask your Agent..."}
defaultValue={initialPrompt}
/>
</footer>
</div>

View file

@ -5,13 +5,7 @@ import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Spinner } from "@multica/ui/components/spinner";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Camera01Icon,
TextIcon,
CheckmarkCircle02Icon,
Alert02Icon,
} from "@hugeicons/core-free-icons";
import { Camera, Type, CheckCircle, AlertCircle } from "lucide-react";
import { QrScannerView } from "@multica/ui/components/qr-scanner-view";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { parseConnectionCode } from "@multica/store";
@ -107,8 +101,7 @@ function RejectedStatus({
return (
<StatusWrapper fullscreen={fullscreen}>
<HugeiconsIcon
icon={Alert02Icon}
<AlertCircle
className="size-14 text-destructive animate-in zoom-in duration-300"
/>
<div className="text-center space-y-1.5">
@ -249,7 +242,7 @@ export function DevicePairing({
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("scan")}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" />
<Camera className="size-3.5" />
Scan
</Button>
<Button
@ -258,7 +251,7 @@ export function DevicePairing({
className="text-xs gap-1.5 h-7 px-3"
onClick={() => setMode("paste")}
>
<HugeiconsIcon icon={TextIcon} className="size-3.5" />
<Type className="size-3.5" />
Paste
</Button>
</div>
@ -287,16 +280,14 @@ export function DevicePairing({
)}
{pasteState === "success" && (
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
<CheckCircle
className="size-14 text-(--tool-success) animate-in zoom-in duration-300"
/>
)}
{pasteState === "error" && (
<div className="flex flex-col items-center justify-center gap-2">
<HugeiconsIcon
icon={Alert02Icon}
<AlertCircle
className="size-12 text-(--tool-error)"
/>
{pasteError && (

View file

@ -1,13 +1,7 @@
"use client"
import { memo, useState, useEffect, useCallback } from "react"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Tick01Icon,
TickDouble01Icon,
Cancel01Icon,
CommandLineIcon,
} from "@hugeicons/core-free-icons"
import { Check, CheckCheck, X, Terminal } from "lucide-react"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
@ -75,7 +69,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
{/* Header: icon + risk label + countdown */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<HugeiconsIcon icon={CommandLineIcon} strokeWidth={2} className="size-3.5 shrink-0" />
<Terminal className="size-3.5 shrink-0" />
<span className="font-medium text-foreground">{riskLabel}</span>
</div>
{remaining !== null && remaining > 0 && !decided && (
@ -113,7 +107,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
className="h-7 text-xs gap-1.5 px-2.5"
onClick={() => handleDecision("allow-once")}
>
<HugeiconsIcon icon={Tick01Icon} strokeWidth={2} className="size-3.5" />
<Check className="size-3.5" />
Allow
</Button>
<Button
@ -122,7 +116,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
className="h-7 text-xs gap-1.5 px-2.5"
onClick={() => handleDecision("allow-always")}
>
<HugeiconsIcon icon={TickDouble01Icon} strokeWidth={2} className="size-3.5" />
<CheckCheck className="size-3.5" />
Always
</Button>
<Button
@ -131,7 +125,7 @@ export const ExecApprovalItem = memo(function ExecApprovalItem({
className="h-7 text-xs gap-1.5 px-2.5"
onClick={() => handleDecision("deny")}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-3.5" />
<X className="size-3.5" />
Deny
</Button>
</div>

View file

@ -1,10 +1,13 @@
import * as React from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import 'katex/dist/katex.min.css'
/**
* Render modes for markdown content:
@ -270,8 +273,8 @@ export function Markdown({
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={components}
>
{processedContent}

View file

@ -49,6 +49,7 @@ function splitIntoBlocks(content: string): Block[] {
const lines = content.split('\n')
let currentBlock = ''
let inCodeBlock = false
let inMathBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? ''
@ -73,6 +74,26 @@ function splitIntoBlocks(content: string): Block[] {
} else if (inCodeBlock) {
// Inside code block - append line
currentBlock += line + '\n'
// Check for display math fence ($$)
} else if (line.trim() === '$$') {
if (!inMathBlock) {
// Starting a math block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inMathBlock = true
currentBlock = line + '\n'
} else {
// Ending a math block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: false })
currentBlock = ''
inMathBlock = false
}
} else if (inMathBlock) {
// Inside math block - append line (don't split on blank lines)
currentBlock += line + '\n'
} else if (line === '') {
// Empty line outside code block = paragraph boundary
if (currentBlock.trim()) {
@ -92,8 +113,8 @@ function splitIntoBlocks(content: string): Block[] {
// Flush remaining content
if (currentBlock) {
blocks.push({
content: inCodeBlock ? currentBlock : currentBlock.trim(),
isCodeBlock: inCodeBlock // Unclosed code block = still streaming
content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
isCodeBlock: inCodeBlock
})
}

View file

@ -42,14 +42,34 @@ function findCodeRanges(text: string): CodeRange[] {
ranges.push({ start: match.index, end: match.index + match[0].length })
}
// Find display math blocks ($$...$$)
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
while ((match = displayMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline math ($...$)
const inlineMathRegex = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
while ((match = inlineMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline code (`...`)
// But skip escaped backticks and code inside fenced blocks
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
while ((match = inlineRegex.exec(text)) !== null) {
const pos = match.index
// Check if this is inside a fenced block
const insideFenced = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideFenced) {
// Check if this is inside a fenced block or math block
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}

View file

@ -1,5 +1,21 @@
import { useState, useEffect } from "react";
import { cn } from "@multica/ui/lib/utils";
interface MulticaIconProps extends React.ComponentProps<"span"> {
/**
* If true, play a one-time entrance spin animation.
*/
animate?: boolean;
/**
* If true, disable hover spin animation.
*/
noSpin?: boolean;
/**
* If true, show a border around the icon.
*/
bordered?: boolean;
}
/**
* Pure CSS 8-pointed asterisk icon matching the Multica logo.
* Uses currentColor so it adapts to light/dark themes automatically.
@ -7,11 +23,62 @@ import { cn } from "@multica/ui/lib/utils";
*/
export function MulticaIcon({
className,
animate = false,
noSpin = false,
bordered = false,
...props
}: React.ComponentProps<"span">) {
}: MulticaIconProps) {
const [entranceDone, setEntranceDone] = useState(!animate);
useEffect(() => {
if (!animate) return;
const timer = setTimeout(() => setEntranceDone(true), 600);
return () => clearTimeout(timer);
}, [animate]);
if (bordered) {
return (
<span
className={cn(
"inline-flex items-center justify-center p-1.5 border border-border rounded-md",
className
)}
aria-hidden="true"
{...props}
>
<span
className={cn(
"block size-3.5",
!entranceDone && "animate-entrance-spin",
entranceDone && !noSpin && "hover:animate-spin"
)}
>
<span
className="block size-full bg-current"
style={{
clipPath: `polygon(
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
)`,
}}
/>
</span>
</span>
);
}
return (
<span
className={cn("inline-block size-[1em] hover:animate-spin", className)}
className={cn(
"inline-block size-[1em]",
!entranceDone && "animate-entrance-spin",
entranceDone && !noSpin && "hover:animate-spin",
className
)}
aria-hidden="true"
{...props}
>

View file

@ -5,14 +5,7 @@ import "./qr-scanner.css"
import { useState, useCallback, useRef, useEffect } from "react"
import { useQrScanner } from "@multica/ui/hooks/use-qr-scanner"
import { Spinner } from "@multica/ui/components/spinner"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Camera01Icon,
Cancel01Icon,
CheckmarkCircle02Icon,
Alert02Icon,
FlashlightIcon,
} from "@hugeicons/core-free-icons"
import { Camera, X, CheckCircle, AlertCircle, Flashlight } from "lucide-react"
type ScannerState =
| "idle"
@ -203,8 +196,7 @@ export function QrScannerView({
onClick={handleStart}
className="flex items-center justify-center size-16 rounded-full bg-foreground/10 hover:bg-foreground/20 transition-colors"
>
<HugeiconsIcon
icon={Camera01Icon}
<Camera
className="size-7 text-muted-foreground"
/>
</button>
@ -252,10 +244,8 @@ export function QrScannerView({
onClick={handleClose}
className="absolute top-3 left-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors z-10"
>
<HugeiconsIcon
icon={Cancel01Icon}
<X
className="size-4 text-white"
strokeWidth={2}
/>
</button>
)}
@ -267,8 +257,7 @@ export function QrScannerView({
onClick={toggleFlash}
className="absolute top-3 right-3 flex items-center justify-center size-8 rounded-full bg-black/40 hover:bg-black/60 transition-colors"
>
<HugeiconsIcon
icon={FlashlightIcon}
<Flashlight
className="size-4 text-white"
/>
</button>
@ -277,8 +266,7 @@ export function QrScannerView({
{/* Success — full overlay */}
{state === "success" && (
<div className="absolute inset-0 flex items-center justify-center bg-[color:var(--tool-success)]/15 animate-in fade-in duration-200">
<HugeiconsIcon
icon={CheckmarkCircle02Icon}
<CheckCircle
className="size-14 text-[color:var(--tool-success)] animate-in zoom-in duration-300"
/>
</div>
@ -287,8 +275,7 @@ export function QrScannerView({
{/* Error — full overlay */}
{state === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-[color:var(--tool-error)]/15 animate-in fade-in duration-200">
<HugeiconsIcon
icon={Alert02Icon}
<AlertCircle
className="size-12 text-[color:var(--tool-error)]"
/>
{errorMessage && (

View file

@ -1,8 +1,7 @@
"use client"
import { useTheme } from "next-themes"
import { HugeiconsIcon } from "@hugeicons/react"
import { Sun01Icon, Moon01Icon, ComputerIcon } from "@hugeicons/core-free-icons"
import { Sun, Moon, Monitor } from "lucide-react"
import {
DropdownMenu,
DropdownMenuTrigger,
@ -19,21 +18,21 @@ export function ThemeToggle() {
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<HugeiconsIcon icon={Sun01Icon} className="dark:hidden" />
<HugeiconsIcon icon={Moon01Icon} className="hidden dark:block" />
<Sun className="dark:hidden" />
<Moon className="hidden dark:block" />
<span>Theme</span>
</SidebarMenuButton>
}
/>
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onClick={() => setTheme("light")}>
<HugeiconsIcon icon={Sun01Icon} /> Light
<Sun /> Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<HugeiconsIcon icon={Moon01Icon} /> Dark
<Moon /> Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<HugeiconsIcon icon={ComputerIcon} /> System
<Monitor /> System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,11 +1,7 @@
"use client"
import { memo, useState } from "react"
import { HugeiconsIcon } from "@hugeicons/react"
import {
AiBrain01Icon,
ArrowRight01Icon,
} from "@hugeicons/core-free-icons"
import { Brain, ChevronRight } from "lucide-react"
import { cn } from "@multica/ui/lib/utils"
interface ThinkingItemProps {
@ -47,9 +43,7 @@ export const ThinkingItem = memo(function ThinkingItem({ thinking, isStreaming }
/>
{/* Icon */}
<HugeiconsIcon
icon={AiBrain01Icon}
strokeWidth={2}
<Brain
className="size-3.5 shrink-0"
/>
@ -67,9 +61,7 @@ export const ThinkingItem = memo(function ThinkingItem({ thinking, isStreaming }
{/* Chevron — visible on hover when expandable */}
{hasContent && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
<ChevronRight
className={cn(
"size-3 text-muted-foreground/40 shrink-0",
"transition-[transform,opacity] duration-150",

View file

@ -1,20 +1,20 @@
"use client"
import { memo, useState } from "react"
import { HugeiconsIcon } from "@hugeicons/react"
import {
File01Icon,
FloppyDiskIcon,
FileEditIcon,
CommandLineIcon,
Search01Icon,
FolderOpenIcon,
GlobeIcon,
DatabaseIcon,
GitBranchIcon,
ChartBarLineIcon,
ArrowRight01Icon,
} from "@hugeicons/core-free-icons"
File,
Save,
FileEdit,
Terminal,
Search,
FolderOpen,
Globe,
Database,
GitBranch,
BarChart3,
ChevronRight,
type LucideIcon,
} from "lucide-react"
import { cn, getTextContent } from "@multica/ui/lib/utils"
import type { Message } from "@multica/store"
@ -22,25 +22,25 @@ import type { Message } from "@multica/store"
// Tool display config
// ---------------------------------------------------------------------------
const TOOL_DISPLAY: Record<string, { label: string; icon: typeof File01Icon }> = {
read: { label: "Read", icon: File01Icon },
write: { label: "Write", icon: FloppyDiskIcon },
edit: { label: "Edit", icon: FileEditIcon },
exec: { label: "Exec", icon: CommandLineIcon },
bash: { label: "Exec", icon: CommandLineIcon },
process: { label: "Process", icon: CommandLineIcon },
grep: { label: "Grep", icon: Search01Icon },
find: { label: "Find", icon: Search01Icon },
ls: { label: "ListDir", icon: FolderOpenIcon },
glob: { label: "Glob", icon: Search01Icon },
web_search: { label: "WebSearch", icon: GlobeIcon },
web_fetch: { label: "WebFetch", icon: GlobeIcon },
memory_get: { label: "MemoryGet", icon: DatabaseIcon },
memory_set: { label: "MemorySet", icon: DatabaseIcon },
memory_delete: { label: "MemoryDelete", icon: DatabaseIcon },
memory_list: { label: "MemoryList", icon: DatabaseIcon },
sessions_spawn: { label: "SpawnSession", icon: GitBranchIcon },
data: { label: "Data", icon: ChartBarLineIcon },
const TOOL_DISPLAY: Record<string, { label: string; icon: LucideIcon }> = {
read: { label: "Read", icon: File },
write: { label: "Write", icon: Save },
edit: { label: "Edit", icon: FileEdit },
exec: { label: "Exec", icon: Terminal },
bash: { label: "Exec", icon: Terminal },
process: { label: "Process", icon: Terminal },
grep: { label: "Grep", icon: Search },
find: { label: "Find", icon: Search },
ls: { label: "ListDir", icon: FolderOpen },
glob: { label: "Glob", icon: Search },
web_search: { label: "WebSearch", icon: Globe },
web_fetch: { label: "WebFetch", icon: Globe },
memory_get: { label: "MemoryGet", icon: Database },
memory_set: { label: "MemorySet", icon: Database },
memory_delete: { label: "MemoryDelete", icon: Database },
memory_list: { label: "MemoryList", icon: Database },
sessions_spawn: { label: "SpawnSession", icon: GitBranch },
data: { label: "Data", icon: BarChart3 },
}
// ---------------------------------------------------------------------------
@ -141,7 +141,7 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
const [expanded, setExpanded] = useState(false)
const { toolName = "", toolStatus = "running", toolArgs, content } = message
const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: CommandLineIcon }
const display = TOOL_DISPLAY[toolName] ?? { label: toolName, icon: Terminal }
const isFinished = toolStatus !== "running"
const resultText = getTextContent(content)
const hasDetails = isFinished && !!resultText
@ -177,9 +177,7 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
/>
{/* Tool icon */}
<HugeiconsIcon
icon={display.icon}
strokeWidth={2}
<display.icon
className={cn("size-3.5 shrink-0", toolStatus === "error" && "text-[var(--tool-error)]")}
/>
@ -212,9 +210,7 @@ export const ToolCallItem = memo(function ToolCallItem({ message }: { message: M
{/* Chevron — visible on hover when expandable */}
{hasDetails && (
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
<ChevronRight
className={cn(
"size-3 text-muted-foreground/40 shrink-0",
"transition-[transform,opacity] duration-150",

View file

@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {

View file

@ -0,0 +1,28 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@multica/ui/lib/utils"
import { Check } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<Check />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -11,8 +11,7 @@ import {
InputGroupButton,
InputGroupInput,
} from "@multica/ui/components/ui/input-group"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowDown01Icon, Cancel01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { ChevronDown, X, Check } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
@ -32,7 +31,7 @@ function ComboboxTrigger({
{...props}
>
{children}
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} className="text-muted-foreground size-4 pointer-events-none" />
<ChevronDown className="text-muted-foreground size-4 pointer-events-none" />
</ComboboxPrimitive.Trigger>
)
}
@ -45,7 +44,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
className={cn(className)}
{...props}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="pointer-events-none" />
<X className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
@ -150,7 +149,7 @@ function ComboboxItem({
<ComboboxPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
<Check className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
@ -246,7 +245,7 @@ function ComboboxChip({
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="pointer-events-none" />
<X className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>

View file

@ -5,8 +5,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { Cancel01Icon } from "@hugeicons/core-free-icons"
import { X } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
@ -71,7 +70,7 @@ function DialogContent({
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<X />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}

View file

@ -4,8 +4,7 @@ import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@multica/ui/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
import { ChevronRight, Check } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
@ -117,7 +116,7 @@ function DropdownMenuSubTrigger({
{...props}
>
{children}
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="ml-auto" />
<ChevronRight className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
@ -164,7 +163,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
<Check />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
@ -200,7 +199,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
<Check />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}

Some files were not shown because too many files have changed in this diff Show more