From 4961604f31b703b610b4432d984102e724591959 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:46:31 +0800 Subject: [PATCH 01/34] feat(desktop): add --reset flag to clear user data for testing - Add reset-user-data.sh script to clear ~/.super-multica/ - Add --reset CLI flag to clear localStorage on startup - Add dev:reset npm script for development testing Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/main/index.ts | 12 +++++++++- package.json | 1 + scripts/reset-user-data.sh | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100755 scripts/reset-user-data.sh diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ad958405..df23ba44 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -44,7 +44,7 @@ process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => { throw err }) -import { app, BrowserWindow, shell, ipcMain } from 'electron' +import { app, BrowserWindow, shell, ipcMain, session } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js' @@ -65,6 +65,7 @@ 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 @@ -113,6 +114,15 @@ 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') + } + // App-level IPC handlers ipcMain.handle('app:getFlags', () => ({ forceOnboarding })) diff --git a/package.json b/package.json index 1da79bb9..d4235ef1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "mu": "pnpm --filter @multica/cli dev", "dev": "pnpm --filter @multica/desktop dev", "dev:desktop": "pnpm --filter @multica/desktop dev", + "dev:reset": "bash scripts/reset-user-data.sh && pnpm --filter @multica/desktop dev -- --reset", "dev:gateway": "pnpm --filter @multica/gateway dev", "dev:web": "pnpm --filter @multica/web dev", "dev:all": "concurrently \"pnpm dev:gateway\" \"pnpm dev:web\"", diff --git a/scripts/reset-user-data.sh b/scripts/reset-user-data.sh new file mode 100755 index 00000000..b56f929a --- /dev/null +++ b/scripts/reset-user-data.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Reset all user data for super-multica desktop app +# Use this to simulate a fresh install for testing + +set -e + +echo "🧹 Resetting Super Multica user data..." + +# Main data directory +MULTICA_DATA_DIR="$HOME/.super-multica" +if [ -d "$MULTICA_DATA_DIR" ]; then + echo " Removing $MULTICA_DATA_DIR" + rm -rf "$MULTICA_DATA_DIR" +else + echo " $MULTICA_DATA_DIR does not exist, skipping" +fi + +# Electron app data (macOS) +if [[ "$OSTYPE" == "darwin"* ]]; then + ELECTRON_APP_DATA="$HOME/Library/Application Support/super-multica" + if [ -d "$ELECTRON_APP_DATA" ]; then + echo " Removing $ELECTRON_APP_DATA" + rm -rf "$ELECTRON_APP_DATA" + else + echo " $ELECTRON_APP_DATA does not exist, skipping" + fi +fi + +# Electron app data (Linux) +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + ELECTRON_APP_DATA="$HOME/.config/super-multica" + if [ -d "$ELECTRON_APP_DATA" ]; then + echo " Removing $ELECTRON_APP_DATA" + rm -rf "$ELECTRON_APP_DATA" + else + echo " $ELECTRON_APP_DATA does not exist, skipping" + fi +fi + +echo "βœ… User data reset complete!" +echo "" +echo "Next steps:" +echo " pnpm dev # Start app (will show onboarding)" +echo " pnpm dev:reset # Reset and start in one command" From 22db61876b16e799f00cab78089bb2d473395580 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:46:21 +0800 Subject: [PATCH 02/34] feat(desktop): add welcome page to onboarding flow - Add Welcome step before the 4-step onboarding flow - Welcome page introduces Multica with brand title (Playfair Display font) - Display three core principles: Your AI, Your Machine, Your Control - Add entrance spin animation to MulticaIcon component - Welcome page has no stepper, only shown after clicking "Start Exploring" Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/renderer/index.html | 8 +++ .../connect-step.tsx} | 24 +++---- .../permissions-step.tsx} | 20 +++--- .../{setup.tsx => components/setup-step.tsx} | 33 ++++------ .../try-it-step.tsx} | 28 +++----- .../onboarding/components/welcome-step.tsx | 65 +++++++++++++++++++ .../renderer/src/pages/onboarding/index.tsx | 56 ++++++++++++++++ .../renderer/src/pages/onboarding/layout.tsx | 32 --------- .../src/renderer/src/stores/onboarding.ts | 11 +++- packages/ui/src/components/multica-icon.tsx | 16 ++++- packages/ui/src/styles/globals.css | 11 ++++ 11 files changed, 209 insertions(+), 95 deletions(-) rename apps/desktop/src/renderer/src/pages/onboarding/{connect.tsx => components/connect-step.tsx} (91%) rename apps/desktop/src/renderer/src/pages/onboarding/{permissions.tsx => components/permissions-step.tsx} (85%) rename apps/desktop/src/renderer/src/pages/onboarding/{setup.tsx => components/setup-step.tsx} (90%) rename apps/desktop/src/renderer/src/pages/onboarding/{try-it.tsx => components/try-it-step.tsx} (84%) create mode 100644 apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx create mode 100644 apps/desktop/src/renderer/src/pages/onboarding/index.tsx delete mode 100644 apps/desktop/src/renderer/src/pages/onboarding/layout.tsx diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index c16bdc87..655326da 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -4,6 +4,14 @@ Multica + + + +
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/connect.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx similarity index 91% rename from apps/desktop/src/renderer/src/pages/onboarding/connect.tsx rename to apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx index 8ca3f7e9..2693b4b6 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/connect.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx @@ -1,13 +1,12 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' import { Input } from '@multica/ui/components/ui/input' import { Badge } from '@multica/ui/components/ui/badge' import { HugeiconsIcon } from '@hugeicons/react' import { ArrowLeft02Icon, Loading03Icon } from '@hugeicons/core-free-icons' -import { useChannels } from '../../hooks/use-channels' -import { TutorialStep } from '../../components/onboarding/tutorial-step' -import { useOnboardingStore } from '../../stores/onboarding' +import { useChannels } from '../../../hooks/use-channels' +import { TutorialStep } from '../../../components/onboarding/tutorial-step' +import { useOnboardingStore } from '../../../stores/onboarding' function statusVariant( status: string @@ -24,8 +23,12 @@ function statusVariant( } } -export default function ConnectStep() { - const navigate = useNavigate() +interface ConnectStepProps { + onNext: () => void + onBack: () => void +} + +export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { const { states, config, saveToken, loading: channelLoading } = useChannels() const { setClientConnected } = useOnboardingStore() @@ -57,16 +60,13 @@ export default function ConnectStep() { setSaving(false) } - const handleContinue = () => navigate('/onboarding/try-it') - const handleBack = () => navigate('/onboarding/setup') - return (
{/* Left column */}
)} -
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/permissions.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx similarity index 85% rename from apps/desktop/src/renderer/src/pages/onboarding/permissions.tsx rename to apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx index faff86d4..b013886b 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/permissions.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx @@ -1,4 +1,3 @@ -import { useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' import { FolderOpenIcon, @@ -6,9 +5,9 @@ import { AiBrainIcon, Database01Icon, } from '@hugeicons/core-free-icons' -import { AcknowledgementItem } from '../../components/onboarding/permission-item' -import { PrivacyPanel } from '../../components/onboarding/privacy-panel' -import { useOnboardingStore } from '../../stores/onboarding' +import { AcknowledgementItem } from '../../../components/onboarding/permission-item' +import { PrivacyPanel } from '../../../components/onboarding/privacy-panel' +import { useOnboardingStore } from '../../../stores/onboarding' const acknowledgementItems = [ { @@ -41,15 +40,14 @@ const acknowledgementItems = [ }, ] -export default function PermissionsStep() { - const navigate = useNavigate() +interface PermissionsStepProps { + onNext: () => void +} + +export default function PermissionsStep({ onNext }: PermissionsStepProps) { const { acknowledgements, allAcknowledged, setAcknowledgement } = useOnboardingStore() - const handleContinue = () => { - navigate('/onboarding/setup') - } - return (
{/* Left column β€” main content, centered both axes */} @@ -83,7 +81,7 @@ export default function PermissionsStep() {
-
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx new file mode 100644 index 00000000..824333c1 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx @@ -0,0 +1,65 @@ +import { Button } from '@multica/ui/components/ui/button' +import { MulticaIcon } from '@multica/ui/components/multica-icon' + +const features = [ + { + title: 'Your AI', + description: 'Choose your preferred model. Extend its abilities with Skills.', + }, + { + title: 'Your Machine', + description: 'Runs locally on your computer. Your data stays with you.', + }, + { + title: 'Your Control', + description: 'You set the boundaries. The AI works within them.', + }, +] + +interface WelcomeStepProps { + onStart: () => void +} + +export default function WelcomeStep({ onStart }: WelcomeStepProps) { + return ( +
+
+ {/* Brand Title */} +
+ +

+ Welcome to Multica +

+
+ + {/* Intro */} +

+ An AI assistant that gets things done β€” pulling data, running analysis, + and taking action. Talk to it like a team member. +

+ + {/* Feature List */} +
+

+ Built on three principles +

+ {features.map((feature) => ( +
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+ + {/* CTA Button */} + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx new file mode 100644 index 00000000..2a9612f7 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx @@ -0,0 +1,56 @@ +import { useNavigate } from 'react-router-dom' +import { Stepper } from '../../components/onboarding/stepper' +import { useOnboardingStore } from '../../stores/onboarding' +import WelcomeStep from './components/welcome-step' +import PermissionsStep from './components/permissions-step' +import SetupStep from './components/setup-step' +import ConnectStep from './components/connect-step' +import TryItStep from './components/try-it-step' + +export default function OnboardingPage() { + const navigate = useNavigate() + const { currentStep, nextStep, prevStep, completeOnboarding } = useOnboardingStore() + + const handleComplete = () => { + completeOnboarding() + navigate('/') + } + + // Welcome step (step 0) has no stepper + if (currentStep === 0) { + return ( +
+ {/* Draggable title bar region for macOS */} +
+
+ +
+
+ ) + } + + return ( +
+ {/* Draggable title bar region for macOS + stepper */} +
+ {/* Spacer for traffic lights */} +
+ +
+ + {/* Step content */} +
+ {currentStep === 1 && } + {currentStep === 2 && } + {currentStep === 3 && } + {currentStep === 4 && } +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/pages/onboarding/layout.tsx b/apps/desktop/src/renderer/src/pages/onboarding/layout.tsx deleted file mode 100644 index eb04d8fa..00000000 --- a/apps/desktop/src/renderer/src/pages/onboarding/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Outlet, useLocation } from 'react-router-dom' -import { Stepper, type StepId } from '../../components/onboarding/stepper' - -export default function OnboardingLayout() { - const location = useLocation() - - // Derive current step from URL path - const pathSegment = location.pathname.split('/').pop() as string - const validSteps: StepId[] = ['permissions', 'setup', 'connect', 'try-it'] - const currentStep: StepId = validSteps.includes(pathSegment as StepId) - ? (pathSegment as StepId) - : 'permissions' - - return ( -
- {/* Draggable title bar region for macOS + stepper */} -
- {/* Spacer for traffic lights */} -
- -
- - {/* Step content */} -
- -
-
- ) -} diff --git a/apps/desktop/src/renderer/src/stores/onboarding.ts b/apps/desktop/src/renderer/src/stores/onboarding.ts index 3282025a..d627e7fc 100644 --- a/apps/desktop/src/renderer/src/stores/onboarding.ts +++ b/apps/desktop/src/renderer/src/stores/onboarding.ts @@ -11,10 +11,14 @@ interface AcknowledgementsState { interface OnboardingStore { completed: boolean forceOnboarding: boolean + currentStep: number acknowledgements: AcknowledgementsState allAcknowledged: boolean providerConfigured: boolean clientConnected: boolean + setStep: (step: number) => void + nextStep: () => void + prevStep: () => void setAcknowledgement: (key: keyof AcknowledgementsState, value: boolean) => void setProviderConfigured: (configured: boolean) => void setClientConnected: (connected: boolean) => void @@ -27,6 +31,7 @@ export const useOnboardingStore = create()( (set, get) => ({ completed: false, forceOnboarding: false, + currentStep: 0, acknowledgements: { fileSystem: false, @@ -38,6 +43,10 @@ export const useOnboardingStore = create()( 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) }), + setAcknowledgement: (key, value) => { const acknowledgements = { ...get().acknowledgements, [key]: value } const allAcknowledged = Object.values(acknowledgements).every(Boolean) @@ -48,7 +57,7 @@ export const useOnboardingStore = create()( setClientConnected: (connected) => set({ clientConnected: connected }), - completeOnboarding: () => set({ completed: true, forceOnboarding: false }), + completeOnboarding: () => set({ completed: true, forceOnboarding: false, currentStep: 0 }), initForceFlag: async () => { const flags = await window.electronAPI.app.getFlags() diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx index cfb222bd..c5d87e30 100644 --- a/packages/ui/src/components/multica-icon.tsx +++ b/packages/ui/src/components/multica-icon.tsx @@ -1,5 +1,12 @@ import { cn } from "@multica/ui/lib/utils"; +interface MulticaIconProps extends React.ComponentProps<"span"> { + /** + * If true, play a one-time entrance spin animation (2 seconds). + */ + animate?: boolean; +} + /** * Pure CSS 8-pointed asterisk icon matching the Multica logo. * Uses currentColor so it adapts to light/dark themes automatically. @@ -7,11 +14,16 @@ import { cn } from "@multica/ui/lib/utils"; */ export function MulticaIcon({ className, + animate = false, ...props -}: React.ComponentProps<"span">) { +}: MulticaIconProps) { return (
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx index b013886b..c17ee9dd 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx @@ -1,42 +1,36 @@ +import { useState } from 'react' import { Button } from '@multica/ui/components/ui/button' +import { Checkbox } from '@multica/ui/components/ui/checkbox' +import { Separator } from '@multica/ui/components/ui/separator' +import { HugeiconsIcon } from '@hugeicons/react' import { FolderOpenIcon, CommandLineIcon, AiBrainIcon, Database01Icon, } from '@hugeicons/core-free-icons' -import { AcknowledgementItem } from '../../../components/onboarding/permission-item' -import { PrivacyPanel } from '../../../components/onboarding/privacy-panel' -import { useOnboardingStore } from '../../../stores/onboarding' +import { StepDots } from './step-dots' -const acknowledgementItems = [ +const capabilities = [ { - key: 'fileSystem' as const, icon: FolderOpenIcon, - title: 'File system access', - description: - 'Multica reads and writes files on your machine to complete tasks you assign.', + title: 'File access', + description: 'Read & write files to complete tasks you assign', }, { - key: 'shellExecution' as const, icon: CommandLineIcon, - title: 'Shell command execution', - description: - 'The agent may run shell commands. Every command requires your explicit approval.', + title: 'Shell commands', + description: 'Run commands β€” every one requires your approval', }, { - key: 'llmRequests' as const, icon: AiBrainIcon, - title: 'LLM API requests', - description: - 'Multica sends prompts to your configured LLM provider. Your API key is used directly.', + title: 'LLM requests', + description: 'Send prompts using your API key directly', }, { - key: 'localStorage' as const, icon: Database01Icon, - title: 'Local data storage', - description: - 'Sessions, profiles, and credentials are stored locally in ~/.super-multica/', + title: 'Local storage', + description: 'Sessions & credentials saved in ~/.super-multica/', }, ] @@ -45,57 +39,67 @@ interface PermissionsStepProps { } export default function PermissionsStep({ onNext }: PermissionsStepProps) { - const { acknowledgements, allAcknowledged, setAcknowledgement } = - useOnboardingStore() + const [agreed, setAgreed] = useState(false) return ( -
- {/* Left column β€” main content, centered both axes */} -
-
-
-

- Privacy & trust -

-

- Multica works locally on your machine. Review what it accesses - and toggle each item to acknowledge. -

-
+
+
+ {/* Header */} +
+

+ Privacy & trust +

+

+ Multica works locally on your machine. Here's what it can do. +

+
-
- {acknowledgementItems.map((item) => ( - - setAcknowledgement(item.key, checked) - } + {/* Capabilities card */} +
+ {capabilities.map((item) => ( +
+
+ +
+
+

{item.title}

+

+ {item.description} +

+
+
+ ))} +
+ + {/* Trust note */} +
+

+ Everything stays on your machine. We have no servers and can't see your data. +

+
+ + + + {/* Footer: dots on left, checkbox + button on right */} +
+ +
+
- -
-
- - {/* Right column β€” privacy panel */} -
-
- -
-
) } diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx index a2c8acbf..f5f9fb04 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx @@ -7,6 +7,7 @@ import { ApiKeyDialog } from '../../../components/api-key-dialog' import { OAuthDialog } from '../../../components/oauth-dialog' import { ProviderSetup } from '../../../components/onboarding/provider-setup' import { TutorialStep } from '../../../components/onboarding/tutorial-step' +import { StepDots } from './step-dots' import { useOnboardingStore } from '../../../stores/onboarding' interface SetupStepProps { @@ -86,7 +87,8 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) {

{error}

)} -
+
+ diff --git a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx index 2a9612f7..b0c7ff4e 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx @@ -1,56 +1,89 @@ -import { useNavigate } from 'react-router-dom' -import { Stepper } from '../../components/onboarding/stepper' -import { useOnboardingStore } from '../../stores/onboarding' -import WelcomeStep from './components/welcome-step' -import PermissionsStep from './components/permissions-step' -import SetupStep from './components/setup-step' -import ConnectStep from './components/connect-step' -import TryItStep from './components/try-it-step' +import { useNavigate } from "react-router-dom"; +import { useOnboardingStore } from "../../stores/onboarding"; +import { MulticaIcon } from "@multica/ui/components/multica-icon"; +import { ModeToggle } from "../../components/mode-toggle"; +import WelcomeStep from "./components/welcome-step"; +import PermissionsStep from "./components/permissions-step"; +import SetupStep from "./components/setup-step"; +import ConnectStep from "./components/connect-step"; +import TryItStep from "./components/try-it-step"; + +const steps = ["Privacy", "Provider", "Connect", "Try it"]; export default function OnboardingPage() { - const navigate = useNavigate() - const { currentStep, nextStep, prevStep, completeOnboarding } = useOnboardingStore() + const navigate = useNavigate(); + const { currentStep, nextStep, prevStep, completeOnboarding } = + useOnboardingStore(); const handleComplete = () => { - completeOnboarding() - navigate('/') - } + completeOnboarding(); + navigate("/"); + }; - // Welcome step (step 0) has no stepper + // Welcome step (step 0) has no header content, just draggable area if (currentStep === 0) { return (
- {/* Draggable title bar region for macOS */} + {/* Draggable title bar region for macOS - same height as main header */}
-
+
- ) + ); } + const stepLabel = steps[currentStep - 1]; + const totalSteps = steps.length; + return (
- {/* Draggable title bar region for macOS + stepper */} -
- {/* Spacer for traffic lights */} -
- +
+ {/* Left: Draggable area for traffic lights */} +
+ + {/* Brand */} +
+ + Multica +
+ + {/* Center: Step indicator */} +
+ + {stepLabel} ({currentStep}/{totalSteps}) + +
+ + {/* Right: Theme toggle */} +
+ +
{/* Step content */} -
+
{currentStep === 1 && } {currentStep === 2 && } - {currentStep === 3 && } - {currentStep === 4 && } + {currentStep === 3 && ( + + )} + {currentStep === 4 && ( + + )}
- ) + ); } diff --git a/packages/ui/src/components/ui/checkbox.tsx b/packages/ui/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..77c41463 --- /dev/null +++ b/packages/ui/src/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" + +import { cn } from "@multica/ui/lib/utils" +import { HugeiconsIcon } from "@hugeicons/react" +import { Tick02Icon } from "@hugeicons/core-free-icons" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox } From 07b8a014aa5a89e2ea1b995183d667c125c3421a Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:43:18 +0800 Subject: [PATCH 09/34] feat(desktop): add resolvedTheme to theme provider - Add resolvedTheme state to expose actual applied theme - Listen for system theme changes when in "system" mode - Fix mode toggle to show correct icon based on selected theme Co-Authored-By: Claude Opus 4.5 --- .../renderer/src/components/mode-toggle.tsx | 20 +++------- .../src/components/theme-provider.tsx | 38 +++++++++++++++---- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/mode-toggle.tsx b/apps/desktop/src/renderer/src/components/mode-toggle.tsx index 19988e05..443a8f69 100644 --- a/apps/desktop/src/renderer/src/components/mode-toggle.tsx +++ b/apps/desktop/src/renderer/src/components/mode-toggle.tsx @@ -1,6 +1,5 @@ import { HugeiconsIcon } from "@hugeicons/react" import { Sun03Icon, Moon02Icon, ComputerIcon } from "@hugeicons/core-free-icons" -import { Button } from "@multica/ui/components/ui/button" import { DropdownMenu, DropdownMenuContent, @@ -10,22 +9,15 @@ import { import { useTheme } from "./theme-provider" export function ModeToggle() { - const { setTheme } = useTheme() + const { theme, setTheme } = useTheme() + + const icon = theme === "light" ? Sun03Icon : theme === "dark" ? Moon02Icon : ComputerIcon return ( - - + + + Toggle theme setTheme("light")}> diff --git a/apps/desktop/src/renderer/src/components/theme-provider.tsx b/apps/desktop/src/renderer/src/components/theme-provider.tsx index abf9600c..cac2f23a 100644 --- a/apps/desktop/src/renderer/src/components/theme-provider.tsx +++ b/apps/desktop/src/renderer/src/components/theme-provider.tsx @@ -10,16 +10,24 @@ type ThemeProviderProps = { type ThemeProviderState = { theme: Theme + resolvedTheme: "light" | "dark" setTheme: (theme: Theme) => void } const initialState: ThemeProviderState = { theme: "system", + resolvedTheme: "light", setTheme: () => null, } const ThemeProviderContext = createContext(initialState) +function getSystemTheme(): "light" | "dark" { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" +} + export function ThemeProvider({ children, defaultTheme = "system", @@ -28,25 +36,39 @@ export function ThemeProvider({ const [theme, setTheme] = useState( () => (localStorage.getItem(storageKey) as Theme) || defaultTheme ) + const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">( + () => (theme === "system" ? getSystemTheme() : theme) + ) useEffect(() => { const root = window.document.documentElement root.classList.remove("light", "dark") - if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light" - root.classList.add(systemTheme) - return + const resolved = theme === "system" ? getSystemTheme() : theme + root.classList.add(resolved) + setResolvedTheme(resolved) + }, [theme]) + + // Listen for system theme changes + useEffect(() => { + if (theme !== "system") return + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => { + const resolved = getSystemTheme() + const root = window.document.documentElement + root.classList.remove("light", "dark") + root.classList.add(resolved) + setResolvedTheme(resolved) } - root.classList.add(theme) + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) }, [theme]) const value = { theme, + resolvedTheme, setTheme: (theme: Theme) => { localStorage.setItem(storageKey, theme) setTheme(theme) From 5ae86feb2b0cf898899e4b86fb6cb06f187ba495 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:43:23 +0800 Subject: [PATCH 10/34] feat(desktop): add global channels store with zustand - Create ChannelsStore for global channel state management - Refactor useChannels hook to use the store - Initialize channels store at app startup - Share state between onboarding and channels page Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/renderer/src/App.tsx | 5 + .../src/renderer/src/hooks/use-channels.ts | 113 ++----------- .../src/renderer/src/stores/channels.ts | 154 ++++++++++++++++++ 3 files changed, 175 insertions(+), 97 deletions(-) create mode 100644 apps/desktop/src/renderer/src/stores/channels.ts diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 2e83ab90..bf706081 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -10,6 +10,8 @@ import ChannelsPage from './pages/channels' import CronsPage from './pages/crons' import OnboardingPage from './pages/onboarding' import { useOnboardingStore } from './stores/onboarding' +import { useProviderStore } from './stores/provider' +import { useChannelsStore } from './stores/channels' function OnboardingGuard({ children }: { children: React.ReactNode }) { const completed = useOnboardingStore((s) => s.completed) @@ -47,6 +49,9 @@ const router = createHashRouter([ export default function App() { useEffect(() => { useOnboardingStore.getState().initForceFlag() + // Prefetch global data at app startup + useProviderStore.getState().fetch() + useChannelsStore.getState().fetch() }, []) return ( diff --git a/apps/desktop/src/renderer/src/hooks/use-channels.ts b/apps/desktop/src/renderer/src/hooks/use-channels.ts index ba8ae386..724b3407 100644 --- a/apps/desktop/src/renderer/src/hooks/use-channels.ts +++ b/apps/desktop/src/renderer/src/hooks/use-channels.ts @@ -1,12 +1,10 @@ /** * Hook for managing channel accounts (Telegram, Discord, etc.) in the Desktop App. * - * Provides state and actions for the Channels settings page: - * - List channel account states (running / stopped / error) - * - Read channel config (tokens) - * - Save / remove tokens with immediate start/stop + * Uses the global ChannelsStore for state management. + * Data is fetched once at app startup and shared across all components. */ -import { useState, useEffect, useCallback } from 'react' +import { useChannelsStore } from '../stores/channels' export interface UseChannelsReturn { /** Runtime states of all channel accounts */ @@ -24,102 +22,23 @@ export interface UseChannelsReturn { /** Remove a bot token β€” stops the bot and removes from file */ removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> /** Stop a channel account without removing config */ - stopChannel: (channelId: string, accountId: string) => Promise + stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> /** Start a channel account from saved config */ - startChannel: (channelId: string, accountId: string) => Promise + startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> } export function useChannels(): UseChannelsReturn { - const [states, setStates] = useState([]) - const [config, setConfig] = useState> | undefined>>({}) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const refresh = useCallback(async () => { - setLoading(true) - setError(null) - - try { - const [stateList, channelConfig] = await Promise.all([ - window.electronAPI.channels.listStates(), - window.electronAPI.channels.getConfig(), - ]) - - setStates(stateList) - setConfig(channelConfig) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - console.error('[useChannels] Failed to load:', message) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - refresh() - }, [refresh]) - - const saveToken = useCallback(async (channelId: string, accountId: string, token: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.saveToken(channelId, accountId, token) - if (!result.ok) { - setError(result.error ?? 'Failed to save token') - } - // Refresh to pick up new state - await refresh() - return result - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - return { ok: false, error: message } - } - }, [refresh]) - - const removeToken = useCallback(async (channelId: string, accountId: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.removeToken(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to remove token') - } - await refresh() - return result - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - return { ok: false, error: message } - } - }, [refresh]) - - const stopChannel = useCallback(async (channelId: string, accountId: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.stop(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to stop channel') - } - await refresh() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - } - }, [refresh]) - - const startChannel = useCallback(async (channelId: string, accountId: string) => { - setError(null) - try { - const result = await window.electronAPI.channels.start(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to start channel') - } - await refresh() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - } - }, [refresh]) + const { + states, + config, + loading, + error, + refresh, + saveToken, + removeToken, + stopChannel, + startChannel, + } = useChannelsStore() return { states, diff --git a/apps/desktop/src/renderer/src/stores/channels.ts b/apps/desktop/src/renderer/src/stores/channels.ts new file mode 100644 index 00000000..5242efbb --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/channels.ts @@ -0,0 +1,154 @@ +import { create } from 'zustand' + +interface ChannelsStore { + // State + states: ChannelAccountStateInfo[] + config: Record> | undefined> + loading: boolean + error: string | null + initialized: boolean + + // Actions + fetch: () => Promise + refresh: () => Promise + saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }> + removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> + stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> + startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> +} + +export const useChannelsStore = create()((set, get) => ({ + states: [], + config: {}, + loading: false, + error: null, + initialized: false, + + fetch: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + const [stateList, channelConfig] = await Promise.all([ + window.electronAPI.channels.listStates(), + window.electronAPI.channels.getConfig(), + ]) + + set({ + states: stateList, + config: channelConfig, + initialized: true, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ChannelsStore] Failed to load:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ loading: true, error: null }) + + try { + const [stateList, channelConfig] = await Promise.all([ + window.electronAPI.channels.listStates(), + window.electronAPI.channels.getConfig(), + ]) + + set({ + states: stateList, + config: channelConfig, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ChannelsStore] Failed to refresh:', message) + } finally { + set({ loading: false }) + } + }, + + saveToken: async (channelId: string, accountId: string, token: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.saveToken(channelId, accountId, token) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to save token' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, + + removeToken: async (channelId: string, accountId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.removeToken(channelId, accountId) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to remove token' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, + + stopChannel: async (channelId: string, accountId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.stop(channelId, accountId) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to stop channel' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, + + startChannel: async (channelId: string, accountId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.channels.start(channelId, accountId) + + if (result.ok) { + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Failed to start channel' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, +})) From fc77cb89d3ecfa757a72cb5160b66e5cb1d447cf Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:45:19 +0800 Subject: [PATCH 11/34] refactor(desktop): migrate provider state to zustand store - Create ProviderStore for global provider state management - Refactor useProvider hook to use the store - Data fetched once at startup, shared across components Co-Authored-By: Claude Opus 4.5 --- .../src/renderer/src/hooks/use-provider.ts | 81 ++++------------ .../src/renderer/src/stores/provider.ts | 92 +++++++++++++++++++ 2 files changed, 111 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/renderer/src/stores/provider.ts diff --git a/apps/desktop/src/renderer/src/hooks/use-provider.ts b/apps/desktop/src/renderer/src/hooks/use-provider.ts index 99cae540..f9c98b15 100644 --- a/apps/desktop/src/renderer/src/hooks/use-provider.ts +++ b/apps/desktop/src/renderer/src/hooks/use-provider.ts @@ -1,14 +1,11 @@ /** * Hook for managing LLM providers in the Desktop App. * - * Provides functionality similar to CLI `/provider` command: - * - List all providers with status - * - Get current provider/model - * - Switch provider/model + * Uses the global ProviderStore for state management. + * Data is fetched once at app startup and shared across all components. */ -import { useState, useEffect, useCallback } from 'react' - -// Types are defined in electron-env.d.ts and available globally +import { useCallback } from 'react' +import { useProviderStore } from '../stores/provider' interface UseProviderReturn { /** All providers with their status */ @@ -30,64 +27,24 @@ interface UseProviderReturn { } export function useProvider(): UseProviderReturn { - const [providers, setProviders] = useState([]) - const [current, setCurrent] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const refresh = useCallback(async () => { - setLoading(true) - setError(null) - - try { - const [providerList, currentInfo] = await Promise.all([ - window.electronAPI.provider.list(), - window.electronAPI.provider.current(), - ]) - - setProviders(providerList) - setCurrent(currentInfo) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - console.error('[useProvider] Failed to load providers:', message) - } finally { - setLoading(false) - } - }, []) - - // Load providers on mount - useEffect(() => { - refresh() - }, [refresh]) - - const setProvider = useCallback(async (providerId: string, modelId?: string) => { - setError(null) - - try { - const result = await window.electronAPI.provider.set(providerId, modelId) - - if (result.ok) { - // Refresh to update current status - await refresh() - return { ok: true } - } else { - setError(result.error ?? 'Unknown error') - return { ok: false, error: result.error } - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - return { ok: false, error: message } - } - }, [refresh]) - - const getProviderMeta = useCallback((providerId: string) => { - return providers.find((p) => p.id === providerId) - }, [providers]) + const { + providers, + current, + loading, + error, + refresh, + setProvider, + } = useProviderStore() const availableProviders = providers.filter((p) => p.available) + const getProviderMeta = useCallback( + (providerId: string) => { + return providers.find((p) => p.id === providerId) + }, + [providers] + ) + return { providers, availableProviders, diff --git a/apps/desktop/src/renderer/src/stores/provider.ts b/apps/desktop/src/renderer/src/stores/provider.ts new file mode 100644 index 00000000..bc35572f --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/provider.ts @@ -0,0 +1,92 @@ +import { create } from 'zustand' + +interface ProviderStore { + // State + providers: ProviderStatus[] + current: CurrentProviderInfo | null + loading: boolean + error: string | null + initialized: boolean + + // Actions + fetch: () => Promise + setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }> + refresh: () => Promise +} + +export const useProviderStore = create()((set, get) => ({ + providers: [], + current: null, + loading: false, + error: null, + initialized: false, + + fetch: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + const [providerList, currentInfo] = await Promise.all([ + window.electronAPI.provider.list(), + window.electronAPI.provider.current(), + ]) + + set({ + providers: providerList, + current: currentInfo, + initialized: true, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ProviderStore] Failed to load providers:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ loading: true, error: null }) + + try { + const [providerList, currentInfo] = await Promise.all([ + window.electronAPI.provider.list(), + window.electronAPI.provider.current(), + ]) + + set({ + providers: providerList, + current: currentInfo, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ProviderStore] Failed to refresh providers:', message) + } finally { + set({ loading: false }) + } + }, + + setProvider: async (providerId: string, modelId?: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.provider.set(providerId, modelId) + + if (result.ok) { + // Refresh to update current status + await get().refresh() + return { ok: true } + } else { + set({ error: result.error ?? 'Unknown error' }) + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, +})) From eb807787d3e9b23fe3c1b9db615fe3c5ee322f6b Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:46:00 +0800 Subject: [PATCH 12/34] feat(desktop): redesign connect step with single column layout - Single column centered layout matching other steps - Improved copy: "Your agent, everywhere" - Info box explaining direct connection to local agent - Coming soon hint for Discord, Slack, Mobile app - Simplified button logic: Skip (outline) + Continue Co-Authored-By: Claude Opus 4.5 --- .../onboarding/components/connect-step.tsx | 282 ++++++++++-------- 1 file changed, 156 insertions(+), 126 deletions(-) diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx index 1d553ee4..6a6c304e 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx @@ -2,12 +2,23 @@ import { useState } from 'react' import { Button } from '@multica/ui/components/ui/button' import { Input } from '@multica/ui/components/ui/input' import { Badge } from '@multica/ui/components/ui/badge' +import { Separator } from '@multica/ui/components/ui/separator' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@multica/ui/components/ui/hover-card' import { HugeiconsIcon } from '@hugeicons/react' -import { ArrowLeft02Icon, Loading03Icon } from '@hugeicons/core-free-icons' +import { + ArrowLeft02Icon, + Loading03Icon, + HelpCircleIcon, + Share08Icon, + Tick02Icon, + InformationCircleIcon, +} from '@hugeicons/core-free-icons' import { useChannels } from '../../../hooks/use-channels' -import { TutorialStep } from '../../../components/onboarding/tutorial-step' import { StepDots } from './step-dots' -import { useOnboardingStore } from '../../../stores/onboarding' function statusVariant( status: string @@ -30,8 +41,7 @@ interface ConnectStepProps { } export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { - const { states, config, saveToken, loading: channelLoading } = useChannels() - const { setClientConnected } = useOnboardingStore() + const { states, config, saveToken } = useChannels() const [token, setToken] = useState('') const [saving, setSaving] = useState(false) @@ -56,141 +66,161 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { setLocalError(result.error ?? 'Failed to connect') } else { setToken('') - setClientConnected(true) } setSaving(false) } return ( -
- {/* Left column */} -
-
- +
+
+ {/* Back button */} + -
-

- Connect a client -

-

- Connect a Telegram bot so you can chat with your agent from - anywhere. You can always set this up later in settings. -

+ {/* Header */} +
+

+ Your agent, everywhere +

+

+ Create bots on messaging platforms that talk to your local agent. +

+
+ + {/* Info box */} +
+

+ Your bot connects directly to this machine β€” + chat from your phone, tablet, or any device. +

+

+ + Telegram now. Discord, Slack, Mobile app coming soon. +

+
+ + {/* Telegram card */} +
+
+
+
+ +
+
+

Telegram

+

+ Bot API long polling +

+
+
+ +
+ {/* Status badge */} + {state && ( + + {state.status} + + )} + + {/* Help hover card */} + + + + + +

+ Get a bot token +

+
    +
  1. + 1. + Open @BotFather in Telegram +
  2. +
  3. + 2. + Send /newbot and name your bot +
  4. +
  5. + 3. + Copy the token and paste below +
  6. +
+
+
+
- {channelLoading ? ( -
- ) : hasToken ? ( -
-
-

Telegram Bot

- {state && ( - - {state.status} - - )} +
+ {hasToken ? ( +
+ +

+ {isRunning + ? 'Bot is running. Send it a message to test.' + : isStarting + ? 'Starting bot...' + : 'Bot configured.'} +

- {state?.status === 'error' && state.error && ( -

{state.error}

- )} - {isRunning && ( -

- Your bot is running. Send it a message on Telegram to test. -

- )} - {isStarting && ( -

- Starting your bot... -

- )} -
- ) : ( -
- setToken(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleConnect()} - /> - {localError && ( -

{localError}

- )} - -
- )} - -
- -
- {!hasToken && ( - - )} - -
+
+ )} + + {localError && ( +

{localError}

+ )} + {state?.status === 'error' && state.error && ( +

{state.error}

+ )}
-
- {/* Right column β€” BotFather tutorial */} -
-
-
-

Create a Telegram bot

-

- Follow these steps to create your bot: -

-
+ -
- - - - -
- -
-

- Why connect Telegram? -

-

- Once connected, you can chat with your Multica agent directly - from Telegram on any device β€” phone, tablet, or desktop. -

+ {/* Footer */} +
+ +
+ +
From 437dc05db0550b356be95715ff67ba212ac27f7f Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:46:17 +0800 Subject: [PATCH 13/34] feat(desktop): improve setup step with radio indicators - Add radio button style indicators for provider selection - Add HoverCard component for provider setup help - Add Link component for external URLs - Add Popover component (dependency for HoverCard) Co-Authored-By: Claude Opus 4.5 --- .../onboarding/components/setup-step.tsx | 293 ++++++++++-------- packages/ui/src/components/ui/hover-card.tsx | 51 +++ packages/ui/src/components/ui/link.tsx | 30 ++ packages/ui/src/components/ui/popover.tsx | 90 ++++++ 4 files changed, 331 insertions(+), 133 deletions(-) create mode 100644 packages/ui/src/components/ui/hover-card.tsx create mode 100644 packages/ui/src/components/ui/link.tsx create mode 100644 packages/ui/src/components/ui/popover.tsx diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx index f5f9fb04..3a5b7906 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx @@ -1,15 +1,23 @@ -import { useState } from 'react' +import React, { useState } from 'react' import { Button } from '@multica/ui/components/ui/button' +import { Separator } from '@multica/ui/components/ui/separator' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@multica/ui/components/ui/hover-card' +import { Link } from '@multica/ui/components/ui/link' import { HugeiconsIcon } from '@hugeicons/react' -import { ArrowLeft02Icon } from '@hugeicons/core-free-icons' +import { ArrowLeft02Icon, HelpCircleIcon } from '@hugeicons/core-free-icons' +import { cn } from '@multica/ui/lib/utils' import { useProvider } from '../../../hooks/use-provider' import { ApiKeyDialog } from '../../../components/api-key-dialog' import { OAuthDialog } from '../../../components/oauth-dialog' -import { ProviderSetup } from '../../../components/onboarding/provider-setup' -import { TutorialStep } from '../../../components/onboarding/tutorial-step' import { StepDots } from './step-dots' import { useOnboardingStore } from '../../../stores/onboarding' +const SUPPORTED_PROVIDERS = ['kimi-coding', 'claude-code', 'openai-codex', 'openrouter'] + interface SetupStepProps { onNext: () => void onBack: () => void @@ -24,11 +32,13 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) { const [oauthDialogOpen, setOauthDialogOpen] = useState(false) const [selectedProvider, setSelectedProvider] = useState(null) - const [focusedProvider, setFocusedProvider] = - useState(null) const hasActiveProvider = current?.available === true + const filteredProviders = SUPPORTED_PROVIDERS + .map((id) => providers.find((p) => p.id === id)) + .filter((p): p is ProviderStatus => p != null) + const handleConfigure = (provider: ProviderStatus) => { setSelectedProvider(provider) if (provider.authMethod === 'oauth') { @@ -39,76 +49,82 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) { } const handleSelect = async (provider: ProviderStatus) => { - await setProvider(provider.id) + if (provider.available) { + await setProvider(provider.id) + } } const handleProviderSuccess = async (modelId?: string) => { await refresh() if (selectedProvider) { await setProvider(selectedProvider.id, modelId) - setFocusedProvider(selectedProvider) } setProviderConfigured(true) } return ( -
- {/* Left column β€” main content, centered both axes */} -
-
- +
+
+ {/* Back button */} + -
-

- Connect an LLM provider -

-

- Multica needs at least one LLM provider to power your AI agent. - Add your API key below. -

-
- - - - {error && ( -

{error}

- )} - -
- - -
+ {/* Header */} +
+

+ Connect a provider +

+

+ Multica needs an LLM provider to work. Add your API key. +

-
- {/* Right column β€” provider tutorial */} -
-
- {focusedProvider ? ( - + {/* Provider cards */} +
+ {loading && filteredProviders.length === 0 ? ( + [1, 2, 3, 4].map((i) => ( +
+ )) ) : ( - + filteredProviders.map((provider) => ( + handleSelect(provider)} + onConfigure={() => handleConfigure(provider)} + /> + )) )}
+ + {error &&

{error}

} + + {/* Trust note */} +
+

+ API keys stay local. Stored in{' '} + + ~/.super-multica/ + {' '} + and never leave your device. +

+
+ + + + {/* Footer */} +
+ + +
{/* Dialogs */} @@ -136,84 +152,95 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) { ) } -function ProviderTutorial({ provider }: { provider: ProviderStatus }) { +function ProviderRow({ + provider, + isActive, + onSelect, + onConfigure, +}: { + provider: ProviderStatus + isActive: boolean + onSelect: () => void + onConfigure: () => void +}) { + const getTutorialSteps = (): React.ReactNode[] => { + if (provider.authMethod === 'oauth') { + return [ + Run: {provider.loginCommand}, + 'Complete login in browser', + 'Click Configure β†’ Refresh', + ] + } + return [ + provider.loginUrl ? ( + Go to {new URL(provider.loginUrl).hostname} + ) : ( + 'Go to provider dashboard' + ), + 'Create a new API key', + 'Click Configure and paste', + ] + } + return ( -
-
-

Set up {provider.name}

-

- Follow these steps to get started: -

+
+
+ {/* Radio indicator */} +
+ {isActive &&
} +
+ +
+

{provider.name}

+

{provider.defaultModel}

+
-
- {provider.authMethod === 'api-key' ? ( - <> - - - - - ) : ( - <> - - - - - )} -
+
+ {/* Help hover card */} + + e.stopPropagation()} + className="p-1 text-muted-foreground hover:text-foreground transition-colors" + > + + + +

Setup {provider.name}

+
    + {getTutorialSteps().map((step, i) => ( +
  1. + {i + 1}. + {step} +
  2. + ))} +
+
+
-
-

- API keys stay local -

-

- Your API keys are stored securely in{' '} - - ~/.super-multica/credentials.json5 - {' '} - and never leave your device. -

-
-
- ) -} - -function DefaultProviderInfo() { - return ( -
-
-

Supported providers

-

- Multica supports multiple LLM providers including OpenAI, - Anthropic, DeepSeek, and more. You can configure additional - providers later in settings. -

-
-
-

API keys stay local

-

- Your API keys are stored securely in{' '} - - ~/.super-multica/credentials.json5 - {' '} - and never leave your device. -

+ {/* Configure button */} +
) diff --git a/packages/ui/src/components/ui/hover-card.tsx b/packages/ui/src/components/ui/hover-card.tsx new file mode 100644 index 00000000..4e67f087 --- /dev/null +++ b/packages/ui/src/components/ui/hover-card.tsx @@ -0,0 +1,51 @@ +"use client" + +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card" + +import { cn } from "@multica/ui/lib/utils" + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ( + + ) +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick< + PreviewCardPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/packages/ui/src/components/ui/link.tsx b/packages/ui/src/components/ui/link.tsx new file mode 100644 index 00000000..5d1c5b28 --- /dev/null +++ b/packages/ui/src/components/ui/link.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import { cn } from "@multica/ui/lib/utils" + +interface LinkProps extends React.AnchorHTMLAttributes { + external?: boolean +} + +const Link = React.forwardRef( + ({ className, external = true, children, ...props }, ref) => { + return ( + + {children} + + ) + } +) +Link.displayName = "Link" + +export { Link } diff --git a/packages/ui/src/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx new file mode 100644 index 00000000..f906507e --- /dev/null +++ b/packages/ui/src/components/ui/popover.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@multica/ui/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} From d189b14a15efe8150e1f71c703919b33fc118085 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:46:56 +0800 Subject: [PATCH 14/34] feat(desktop): add StepDots component and minor onboarding tweaks - Add StepDots progress indicator to all steps - Update animations for consistency - Minor copy and layout adjustments Co-Authored-By: Claude Opus 4.5 --- .../components/permissions-step.tsx | 2 +- .../onboarding/components/try-it-step.tsx | 181 +++++++++--------- .../onboarding/components/welcome-step.tsx | 2 +- .../renderer/src/pages/onboarding/index.tsx | 4 +- 4 files changed, 93 insertions(+), 96 deletions(-) diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx index c17ee9dd..e9832b00 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/permissions-step.tsx @@ -42,7 +42,7 @@ export default function PermissionsStep({ onNext }: PermissionsStepProps) { const [agreed, setAgreed] = useState(false) return ( -
+
{/* Header */}
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx index e6d43b69..4435991a 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/try-it-step.tsx @@ -1,27 +1,34 @@ +import { useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' -import { Loading } from '@multica/ui/components/ui/loading' -import { ChatView } from '@multica/ui/components/chat-view' +import { Separator } from '@multica/ui/components/ui/separator' import { HugeiconsIcon } from '@hugeicons/react' -import { ArrowLeft02Icon } from '@hugeicons/core-free-icons' -import { SamplePrompt } from '../../../components/onboarding/sample-prompt' +import { + ArrowLeft02Icon, + ArrowRight01Icon, + Search01Icon, + FolderOpenIcon, + CommandLineIcon, +} from '@hugeicons/core-free-icons' import { StepDots } from './step-dots' -import { useLocalChat } from '../../../hooks/use-local-chat' -const samplePrompts = [ +const tryPrompts = [ { - title: 'Latest AI news', - prompt: - "Search the web for today's top AI news and give me a 3-bullet summary with sources.", + icon: Search01Icon, + title: 'Search the web', + description: "Get today's AI news", + prompt: "Search the web for today's top AI news and give me a 3-bullet summary with sources.", }, { - title: 'Analyze this project', - prompt: - 'Look at the files in my current directory and give me a brief summary of what this project is about.', + icon: FolderOpenIcon, + title: 'Read your files', + description: 'Summarize this directory', + prompt: 'Look at the files in my current directory and give me a brief summary of what this project is about.', }, { - title: 'Quick task', - prompt: - 'Write a one-liner shell command that shows my system info (OS, CPU cores, memory) and run it.', + icon: CommandLineIcon, + title: 'Run a command', + description: 'Show system info', + prompt: 'Write a one-liner shell command that shows my system info (OS, CPU cores, memory) and run it.', }, ] @@ -31,91 +38,81 @@ interface TryItStepProps { } export default function TryItStep({ onComplete, onBack }: TryItStepProps) { - const { - agentId, - initError, - messages, - streamingIds, - isLoading, - isLoadingHistory, - isLoadingMore, - hasMore, - error, - pendingApprovals, - sendMessage, - loadMore, - resolveApproval, - } = useLocalChat() + const navigate = useNavigate() + + const handlePromptClick = (prompt: string) => { + console.log('[TryItStep] Selected prompt:', prompt) + // TODO: Pass prompt to chat page + onComplete() + navigate('/chat') + } return ( -
- {/* Left column β€” prompts */} -
-
- +
+
+ {/* Back button */} + -
-

- Try it out -

-

- Your agent can search the web, read files, and run commands. - Click a prompt to see it in action. -

-
+ {/* Header */} +
+

+ You're all set πŸŽ‰ +

+

+ Your agent is ready to help. Try a sample task, or dive right in. +

+
-
- {samplePrompts.map((sp) => ( - sendMessage(sp.prompt)} - /> + {/* Try prompts */} +
+

+ ✨ Quick start +

+
+ {tryPrompts.map((item) => ( + ))}
- -
- - -
-
- {/* Right column β€” live chat */} -
- {initError ? ( -
- {initError} -
- ) : !agentId ? ( -
- - Initializing agent... -
- ) : ( - - )} + + + {/* Footer */} +
+ + +
) diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx index c779cf22..2e278cf2 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/welcome-step.tsx @@ -22,7 +22,7 @@ interface WelcomeStepProps { export default function WelcomeStep({ onStart }: WelcomeStepProps) { return ( -
+
{/* Brand Title */}
diff --git a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx index b0c7ff4e..6dbfe723 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/index.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/index.tsx @@ -31,7 +31,7 @@ export default function OnboardingPage() { />
@@ -73,7 +73,7 @@ export default function OnboardingPage() { {/* Step content */}
{currentStep === 1 && } {currentStep === 2 && } From b825c6b3d8aabe5b191d2b25297ac2c158216af8 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:47:35 +0800 Subject: [PATCH 15/34] feat(ui): improve MulticaIcon animation behavior - Separate entrance animation from hover animation - Use state to track entrance completion - Hover spin only activates after entrance is done - Shorten entrance animation to 0.6s Co-Authored-By: Claude Opus 4.5 --- packages/ui/src/components/multica-icon.tsx | 16 +++++++++++++--- packages/ui/src/styles/globals.css | 10 +++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx index c5d87e30..7c376175 100644 --- a/packages/ui/src/components/multica-icon.tsx +++ b/packages/ui/src/components/multica-icon.tsx @@ -1,8 +1,9 @@ +import { useState, useEffect } from "react"; import { cn } from "@multica/ui/lib/utils"; interface MulticaIconProps extends React.ComponentProps<"span"> { /** - * If true, play a one-time entrance spin animation (2 seconds). + * If true, play a one-time entrance spin animation. */ animate?: boolean; } @@ -17,11 +18,20 @@ export function MulticaIcon({ animate = false, ...props }: MulticaIconProps) { + const [entranceDone, setEntranceDone] = useState(!animate); + + useEffect(() => { + if (!animate) return; + const timer = setTimeout(() => setEntranceDone(true), 600); + return () => clearTimeout(timer); + }, [animate]); + return (