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
|
|
@ -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"
|
||||
|
|
|
|||
4
apps/desktop/src/main/electron-env.d.ts
vendored
4
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
apps/desktop/src/main/ipc/app-state.ts
Normal file
31
apps/desktop/src/main/ipc/app-state.ts
Normal 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}`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
126
packages/core/src/app-state.ts
Normal file
126
packages/core/src/app-state.ts
Normal 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();
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue