feat(desktop): persist onboarding state with --force-onboarding flag

Use Zustand persist middleware with localStorage to remember onboarding
completion across app restarts. Only the completed flag is persisted;
transient UI state resets each launch. Add --force-onboarding CLI flag
to re-show onboarding even when already completed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-02-10 23:15:10 +08:00
parent ab65f4cadf
commit 04d227c9fe
5 changed files with 62 additions and 22 deletions

View file

@ -139,6 +139,9 @@ interface ChannelAccountStateInfo {
}
interface ElectronAPI {
app: {
getFlags: () => Promise<{ forceOnboarding: boolean }>
}
hub: {
init: () => Promise<unknown>
getStatus: () => Promise<HubStatus>

View file

@ -44,7 +44,7 @@ process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => {
throw err
})
import { app, BrowserWindow, shell } 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'
@ -63,6 +63,9 @@ export const RENDERER_DIST = path.join(__dirname, '../renderer')
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
// CLI flags
const forceOnboarding = process.argv.includes('--force-onboarding')
let win: BrowserWindow | null
function createWindow() {
@ -110,6 +113,9 @@ app.on('before-quit', () => {
})
app.whenReady().then(async () => {
// App-level IPC handlers
ipcMain.handle('app:getFlags', () => ({ forceOnboarding }))
// Register all IPC handlers before creating window
registerAllIpcHandlers()

View file

@ -97,6 +97,12 @@ export interface LocalChatApproval {
// ============================================================================
const electronAPI = {
// App-level
app: {
/** Get CLI flags passed to the app */
getFlags: (): Promise<{ forceOnboarding: boolean }> => ipcRenderer.invoke('app:getFlags'),
},
// Hub management
hub: {
init: () => ipcRenderer.invoke('hub:init'),

View file

@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'
import Layout from './pages/layout'
import HomePage from './pages/home'
@ -15,7 +16,8 @@ import { useOnboardingStore } from './stores/onboarding'
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const completed = useOnboardingStore((s) => s.completed)
if (!completed) return <Navigate to="/onboarding" replace />
const forceOnboarding = useOnboardingStore((s) => s.forceOnboarding)
if (!completed || forceOnboarding) return <Navigate to="/onboarding" replace />
return <>{children}</>
}
@ -52,5 +54,9 @@ const router = createHashRouter([
])
export default function App() {
useEffect(() => {
useOnboardingStore.getState().initForceFlag()
}, [])
return <RouterProvider router={router} />
}

View file

@ -1,4 +1,5 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface AcknowledgementsState {
fileSystem: boolean
@ -9,6 +10,7 @@ interface AcknowledgementsState {
interface OnboardingStore {
completed: boolean
forceOnboarding: boolean
acknowledgements: AcknowledgementsState
allAcknowledged: boolean
providerConfigured: boolean
@ -17,30 +19,47 @@ interface OnboardingStore {
setProviderConfigured: (configured: boolean) => void
setClientConnected: (connected: boolean) => void
completeOnboarding: () => void
initForceFlag: () => Promise<void>
}
export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
completed: false,
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set, get) => ({
completed: false,
forceOnboarding: false,
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,
setAcknowledgement: (key, value) => {
const acknowledgements = { ...get().acknowledgements, [key]: value }
const allAcknowledged = Object.values(acknowledgements).every(Boolean)
set({ acknowledgements, allAcknowledged })
},
setAcknowledgement: (key, value) => {
const acknowledgements = { ...get().acknowledgements, [key]: value }
const allAcknowledged = Object.values(acknowledgements).every(Boolean)
set({ acknowledgements, allAcknowledged })
},
setProviderConfigured: (configured) => set({ providerConfigured: configured }),
setProviderConfigured: (configured) => set({ providerConfigured: configured }),
setClientConnected: (connected) => set({ clientConnected: connected }),
setClientConnected: (connected) => set({ clientConnected: connected }),
completeOnboarding: () => set({ completed: true }),
}))
completeOnboarding: () => set({ completed: true }),
initForceFlag: async () => {
const flags = await window.electronAPI.app.getFlags()
if (flags.forceOnboarding) {
set({ forceOnboarding: true })
}
},
}),
{
name: 'multica-onboarding',
partialize: (state) => ({ completed: state.completed }),
}
)
)