feat(desktop): persist onboarding state to file system

- Add AppState module in core for managing app state persistence
- Add app-state IPC handlers for reading/writing onboarding state
- Hydrate onboarding state from file system on app startup
- Prevent flash by showing blank screen during hydration
- Update onboarding store to sync with file system
- Improve MulticaIcon with enhanced animation states
- Minor UI fixes in chat and device list components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-12 10:53:04 +08:00
parent cf3ad1db91
commit eb4e1f57b1
20 changed files with 360 additions and 67 deletions

View file

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

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

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

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

@ -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 (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<div className="h-dvh bg-background" />
</ThemeProvider>
)
}
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">

View file

@ -96,7 +96,14 @@ export function DeviceList() {
}
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 (

View file

@ -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 (
<div className="flex-1 flex items-center justify-center text-sm text-destructive">

View file

@ -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 (
<div className="h-full flex flex-col overflow-hidden">
<LocalChat />
<LocalChat initialPrompt={initialPrompt} />
</div>
)
}

View file

@ -563,7 +563,7 @@ export default function HomePage() {
{/* Left: Connect */}
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Remote Access</h3>
<h3 className="text-sm font-medium">Control from Anywhere</h3>
<Button
variant="outline"
size="sm"
@ -573,8 +573,8 @@ export default function HomePage() {
{qrCodeExpanded ? 'Hide' : 'Show'}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Scan with your phone to connect remotely.
<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 */}

View file

@ -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() {
</SidebarHeader>
<SidebarContent>
{/* Brand */}
<div className="flex items-center gap-2 px-3 py-2">
<MulticaIcon bordered noSpin />
<span className="text-sm font-brand">Multica</span>
</div>
{/* Main navigation */}
<SidebarGroup>
<SidebarMenu className="space-y-0.5">

View file

@ -32,18 +32,17 @@ const tryPrompts = [
]
interface TryItStepProps {
onComplete: () => void
onComplete: () => void | Promise<void>
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 */}
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Ready to go
🎉 Ready to go
</h1>
<p className="text-sm text-muted-foreground">
Your agent is ready. Try a sample task or dive right in.

View file

@ -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 */}
<div className="flex items-center gap-2 shrink-0">
<MulticaIcon className="size-4 text-muted-foreground/70" />
<MulticaIcon bordered noSpin />
<span className="text-sm tracking-wide font-brand">Multica</span>
</div>

View file

@ -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<void>
}
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set, get) => ({
completed: false,
currentStep: 0,
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,
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 })
},
}))

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

@ -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<ChatInputRef, ChatInputProps>(
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<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 }) {

View file

@ -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<HTMLElement>(null);
const sentinelRef = useRef<HTMLDivElement>(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}
/>
</footer>
</div>

View file

@ -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 (
<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]",
!entranceDone && "animate-entrance-spin",
entranceDone && "hover:animate-spin",
entranceDone && !noSpin && "hover:animate-spin",
className
)}
aria-hidden="true"