diff --git a/CLAUDE.md b/CLAUDE.md index 48244241..abdc22f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 446fe137..41d9da13 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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:", diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 3c2bc150..4f4099c1 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -142,6 +142,10 @@ interface ElectronAPI { app: { getFlags: () => Promise<{ forceOnboarding: boolean }> } + appState: { + getOnboardingCompleted: () => Promise + setOnboardingCompleted: (completed: boolean) => Promise + } hub: { init: () => Promise getStatus: () => Promise diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 509452fd..a4428d31 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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 })) diff --git a/apps/desktop/src/main/ipc/app-state.ts b/apps/desktop/src/main/ipc/app-state.ts new file mode 100644 index 00000000..9683e4d2 --- /dev/null +++ b/apps/desktop/src/main/ipc/app-state.ts @@ -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 => { + return appStateManager.getOnboardingCompleted() + }) + + /** + * Set onboarding completed status. + */ + ipcMain.handle( + 'appState:setOnboardingCompleted', + async (_event, completed: boolean): Promise => { + appStateManager.setOnboardingCompleted(completed) + console.log(`[IPC] Onboarding completed set to: ${completed}`) + } + ) +} diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index b733a320..2080e15f 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -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() } /** diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 322ebb18..cc70dc46 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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 => ipcRenderer.invoke('appState:getOnboardingCompleted'), + /** Set onboarding completed status */ + setOnboardingCompleted: (completed: boolean): Promise => ipcRenderer.invoke('appState:setOnboardingCompleted', completed), + }, + // Hub management hub: { init: () => ipcRenderer.invoke('hub:init'), diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index c16bdc87..655326da 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -4,6 +4,14 @@ Multica + + + +
diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 7865bbbe..51003d2f 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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 + if (!completed) return return <>{children} } const router = createHashRouter([ { path: '/onboarding', - element: , - children: [ - { index: true, element: }, - { path: 'setup', element: }, - { path: 'connect', element: }, - { path: 'try-it', element: }, - ], + element: , }, { 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 + 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 ( + +
+ + ) + } + + return ( + + + + + + + ) } diff --git a/apps/desktop/src/renderer/src/components/agent-settings-dialog.tsx b/apps/desktop/src/renderer/src/components/agent-settings-dialog.tsx index bcd28a17..a8223bee 100644 --- a/apps/desktop/src/renderer/src/components/agent-settings-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/agent-settings-dialog.tsx @@ -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 ? (
- +
) : (
@@ -109,7 +108,7 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP Cancel diff --git a/apps/desktop/src/renderer/src/components/api-key-dialog.tsx b/apps/desktop/src/renderer/src/components/api-key-dialog.tsx index d07a4a5d..54864853 100644 --- a/apps/desktop/src/renderer/src/components/api-key-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/api-key-dialog.tsx @@ -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({ - + Configure {providerName} @@ -153,21 +152,21 @@ export function ApiKeyDialog({ {/* Status messages */} {phase === 'saving' && (
- + Saving API key...
)} {phase === 'testing' && (
- + Testing connection...
)} {phase === 'success' && (
- + Connected successfully!
)} diff --git a/apps/desktop/src/renderer/src/components/cron-job-list.tsx b/apps/desktop/src/renderer/src/components/cron-job-list.tsx index d01a2613..5fb26718 100644 --- a/apps/desktop/src/renderer/src/components/cron-job-list.tsx +++ b/apps/desktop/src/renderer/src/components/cron-job-list.tsx @@ -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 ( - + {config.label} ) @@ -101,7 +100,7 @@ export function CronJobList({ if (loading && jobs.length === 0) { return (
- + Loading cron jobs...
) @@ -121,10 +120,11 @@ export function CronJobList({ className="gap-1.5" disabled={loading} > - + {loading ? ( + + ) : ( + + )} Refresh
@@ -139,7 +139,7 @@ export function CronJobList({ {/* Empty state */} {jobs.length === 0 && !loading && (
- +

No scheduled tasks

Use the cron tool in Chat to create one. @@ -189,13 +189,13 @@ export function CronJobList({ disabled={isRemoving} > {isRemoving ? ( - + ) : ( - + )} {isToggling && ( - + )}

- +
{displayName}
@@ -80,9 +79,9 @@ function DeviceItem({ disabled={revoking} > {revoking ? ( - + ) : ( - + )}
@@ -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 ( +
+ +

+ No devices connected yet. +

+
+ ) } return ( @@ -111,8 +117,13 @@ export function DeviceList() { size="sm" className="h-7 px-2 text-xs gap-1" onClick={refresh} + disabled={refreshing} > - + {refreshing ? ( + + ) : ( + + )} Refresh
diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index 4faf28df..b62c9e6b 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -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 (
diff --git a/apps/desktop/src/renderer/src/components/mode-toggle.tsx b/apps/desktop/src/renderer/src/components/mode-toggle.tsx new file mode 100644 index 00000000..b65c5995 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/mode-toggle.tsx @@ -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 ( + + + + Toggle theme + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ) +} diff --git a/apps/desktop/src/renderer/src/components/oauth-dialog.tsx b/apps/desktop/src/renderer/src/components/oauth-dialog.tsx index e6573cf0..3631bbd9 100644 --- a/apps/desktop/src/renderer/src/components/oauth-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/oauth-dialog.tsx @@ -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({ - + Configure {providerName} @@ -116,7 +115,7 @@ export function OAuthDialog({ {success && (
- + Credentials imported successfully! {expiresAt && ` (expires in ${formatExpiry(expiresAt)})`} @@ -131,9 +130,9 @@ export function OAuthDialog({ diff --git a/apps/desktop/src/renderer/src/components/onboarding/permission-item.tsx b/apps/desktop/src/renderer/src/components/onboarding/permission-item.tsx index d11ebdcd..dc07a94f 100644 --- a/apps/desktop/src/renderer/src/components/onboarding/permission-item.tsx +++ b/apps/desktop/src/renderer/src/components/onboarding/permission-item.tsx @@ -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 (