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:
parent
cf3ad1db91
commit
eb4e1f57b1
20 changed files with 360 additions and 67 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue