diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d09a1e98..9730e107 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,6 +8,7 @@ "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" diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 04eb0643..e0c7c3ab 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 f9cfb17b..47fba83b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -44,10 +44,11 @@ process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => { throw err }) -import { app, BrowserWindow, shell, ipcMain, session } from 'electron' +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' // CJS output will have __dirname natively, but TypeScript source needs this for type checking const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -65,7 +66,6 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, // CLI flags const forceOnboarding = process.argv.includes('--force-onboarding') -const resetUserData = process.argv.includes('--reset') let win: BrowserWindow | null @@ -116,13 +116,11 @@ app.on('before-quit', () => { }) app.whenReady().then(async () => { - // Reset user data if --reset flag is passed (for development testing) - if (resetUserData) { - console.log('[reset] Clearing localStorage...') - await session.defaultSession.clearStorageData({ - storages: ['localstorage'] - }) - console.log('[reset] localStorage cleared') + // 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 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 44acd172..97d120bc 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/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 95cc772b..54135b35 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -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' @@ -51,14 +51,42 @@ const router = createHashRouter([ ]) export default function App() { + const [isHydrated, setIsHydrated] = useState(false) + const setCompleted = useOnboardingStore((s) => s.setCompleted) + useEffect(() => { + // Load onboarding state from file system + async function hydrateOnboardingState() { + try { + const completed = await window.electronAPI.appState.getOnboardingCompleted() + setCompleted(completed) + } catch (err) { + console.error('[App] Failed to load onboarding state:', err) + // Default to false if load fails + setCompleted(false) + } finally { + setIsHydrated(true) + } + } + + hydrateOnboardingState() + // Prefetch global data at app startup 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/device-list.tsx b/apps/desktop/src/renderer/src/components/device-list.tsx index 6ff2045d..6c208476 100644 --- a/apps/desktop/src/renderer/src/components/device-list.tsx +++ b/apps/desktop/src/renderer/src/components/device-list.tsx @@ -96,7 +96,14 @@ export function DeviceList() { } if (devices.length === 0) { - return null + return ( +
+ +

+ No devices connected yet. +

+
+ ) } return ( diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index 041b5ead..e1de374c 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -1,4 +1,5 @@ -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' @@ -6,7 +7,12 @@ 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, @@ -56,6 +62,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/pages/chat.tsx b/apps/desktop/src/renderer/src/pages/chat.tsx index 8900a10a..7170d608 100644 --- a/apps/desktop/src/renderer/src/pages/chat.tsx +++ b/apps/desktop/src/renderer/src/pages/chat.tsx @@ -1,9 +1,13 @@ +import { useSearchParams } from 'react-router-dom' import { LocalChat } from '../components/local-chat' export default function ChatPage() { + const [searchParams] = useSearchParams() + const initialPrompt = searchParams.get('prompt') ?? undefined + return (
- +
) } diff --git a/apps/desktop/src/renderer/src/pages/home.tsx b/apps/desktop/src/renderer/src/pages/home.tsx index cb51eb3c..1072ec6a 100644 --- a/apps/desktop/src/renderer/src/pages/home.tsx +++ b/apps/desktop/src/renderer/src/pages/home.tsx @@ -563,7 +563,7 @@ export default function HomePage() { {/* Left: Connect */}
-

Remote Access

+

Control from Anywhere

-

- Scan with your phone to connect remotely. +

+ Open Multica Web on your phone and scan. Operate your computer and use your agent remotely.

{/* QR Code Container */} diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index e9cf256d..58f750cf 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -1,12 +1,13 @@ import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' +import { MulticaIcon } from '@multica/ui/components/multica-icon' import { Home, MessageSquare, Puzzle, Wrench, - Mail, - Repeat, + Radio, + Clock, ChevronLeft, ChevronRight, } from 'lucide-react' @@ -36,8 +37,8 @@ const mainNavItems = [ const configNavItems = [ { path: '/skills', label: 'Skills', icon: Puzzle }, { path: '/tools', label: 'Tools', icon: Wrench }, - { path: '/channels', label: 'Channels', icon: Mail }, - { path: '/crons', label: 'Crons', icon: Repeat }, + { path: '/channels', label: 'Channels', icon: Radio }, + { path: '/crons', label: 'Crons', icon: Clock }, ] // All nav items for header lookup @@ -134,6 +135,12 @@ export default function Layout() { + {/* Brand */} +
+ + Multica +
+ {/* Main navigation */} diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx index 69ef582c..984925ff 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx @@ -32,18 +32,17 @@ const tryPrompts = [ ] interface TryItStepProps { - onComplete: () => void + onComplete: () => void | Promise onBack: () => void } export default function TryItStep({ onComplete, onBack }: TryItStepProps) { const navigate = useNavigate() - const handlePromptClick = (prompt: string) => { + const handlePromptClick = async (prompt: string) => { console.log('[TryItStep] Selected prompt:', prompt) - // TODO: Pass prompt to chat page - onComplete() - navigate('/chat') + await onComplete() + navigate(`/chat?prompt=${encodeURIComponent(prompt)}`) } return ( @@ -61,7 +60,7 @@ export default function TryItStep({ onComplete, onBack }: TryItStepProps) { {/* Header */}

- Ready to go + 🎉 Ready to go

Your agent is ready. Try a sample task or dive right in. diff --git a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx index 97d6fd71..fb43dc69 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx @@ -15,8 +15,8 @@ export default function OnboardingPage() { const { currentStep, nextStep, prevStep, completeOnboarding } = useOnboardingStore(); - const handleComplete = () => { - completeOnboarding(); + const handleComplete = async () => { + await completeOnboarding(); navigate("/"); }; @@ -53,7 +53,7 @@ export default function OnboardingPage() { {/* Brand */}

- + Multica
diff --git a/apps/desktop/src/renderer/src/stores/onboarding.ts b/apps/desktop/src/renderer/src/stores/onboarding.ts index 3b9cbb17..3bc9b21c 100644 --- a/apps/desktop/src/renderer/src/stores/onboarding.ts +++ b/apps/desktop/src/renderer/src/stores/onboarding.ts @@ -1,5 +1,4 @@ import { create } from "zustand" -import { persist } from "zustand/middleware" interface AcknowledgementsState { fileSystem: boolean @@ -9,56 +8,60 @@ interface AcknowledgementsState { } interface OnboardingStore { + // Persisted state (loaded from file system via IPC) completed: 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 + completeOnboarding: () => Promise } -export const useOnboardingStore = create()( - persist( - (set, get) => ({ - completed: false, - currentStep: 0, +export const useOnboardingStore = create()((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, - setStep: (step) => set({ currentStep: step }), - nextStep: () => set({ currentStep: Math.min(get().currentStep + 1, 4) }), - prevStep: () => set({ currentStep: Math.max(get().currentStep - 1, 0) }), + setCompleted: (completed) => set({ completed }), - setAcknowledgement: (key, value) => { - const acknowledgements = { ...get().acknowledgements, [key]: value } - const allAcknowledged = Object.values(acknowledgements).every(Boolean) - set({ acknowledgements, allAcknowledged }) - }, + setStep: (step) => set({ currentStep: step }), + nextStep: () => set({ currentStep: Math.min(get().currentStep + 1, 4) }), + prevStep: () => set({ currentStep: Math.max(get().currentStep - 1, 0) }), - setProviderConfigured: (configured) => set({ providerConfigured: configured }), + setAcknowledgement: (key, value) => { + const acknowledgements = { ...get().acknowledgements, [key]: value } + const allAcknowledged = Object.values(acknowledgements).every(Boolean) + set({ acknowledgements, allAcknowledged }) + }, - setClientConnected: (connected) => set({ clientConnected: connected }), + setProviderConfigured: (configured) => set({ providerConfigured: configured }), - completeOnboarding: () => set({ completed: true, currentStep: 0 }), - }), - { - 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 }) + }, +})) diff --git a/packages/core/src/app-state.ts b/packages/core/src/app-state.ts new file mode 100644 index 00000000..469f0aa2 --- /dev/null +++ b/packages/core/src/app-state.ts @@ -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(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d2baf8d..93b6b560 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/ui/src/components/chat-input.tsx b/packages/ui/src/components/chat-input.tsx index f0bbe44f..2f279d27 100644 --- a/packages/ui/src/components/chat-input.tsx +++ b/packages/ui/src/components/chat-input.tsx @@ -19,10 +19,12 @@ interface ChatInputProps { onSubmit?: (value: string) => void; disabled?: boolean; placeholder?: string; + /** Initial value to pre-fill the input */ + defaultValue?: string; } export const ChatInput = forwardRef( - function ChatInput({ onSubmit, disabled, placeholder = "Type a message..." }, ref) { + function ChatInput({ onSubmit, disabled, placeholder = "Type a message...", defaultValue }, ref) { // Use ref to avoid stale closure in Tiptap keydown handler const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; @@ -45,6 +47,7 @@ export const ChatInput = forwardRef( }), Placeholder.configure({ placeholder }), ], + content: defaultValue ? `

${defaultValue}

` : "", immediatelyRender: false, // Scroll cursor into view on every content change (e.g., Shift+Enter newlines) onUpdate({ editor }) { diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index f5094090..83943f73 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -41,6 +41,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({ @@ -57,6 +59,7 @@ export function ChatView({ resolveApproval, onDisconnect, errorAction, + initialPrompt, }: ChatViewProps) { const mainRef = useRef(null); const sentinelRef = useRef(null); @@ -259,6 +262,7 @@ export function ChatView({ onSubmit={sendMessage} disabled={isLoading || (!!error && error.code !== 'AGENT_ERROR')} placeholder={error && error.code !== 'AGENT_ERROR' ? "Connection error" : "Ask your Agent..."} + defaultValue={initialPrompt} />
diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx index 7c376175..ac2401ef 100644 --- a/packages/ui/src/components/multica-icon.tsx +++ b/packages/ui/src/components/multica-icon.tsx @@ -6,6 +6,14 @@ 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; } /** @@ -16,6 +24,8 @@ interface MulticaIconProps extends React.ComponentProps<"span"> { export function MulticaIcon({ className, animate = false, + noSpin = false, + bordered = false, ...props }: MulticaIconProps) { const [entranceDone, setEntranceDone] = useState(!animate); @@ -26,12 +36,47 @@ export function MulticaIcon({ return () => clearTimeout(timer); }, [animate]); + if (bordered) { + return ( +