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

@ -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 })
},
}))