feat(desktop): redesign privacy step with single column layout

- Simplify permissions step to single centered column
- Replace per-item switches with single "I understand" checkbox
- Add capabilities info card with clean dividers
- Add trust note section for privacy messaging
- Rename step from "Permissions" to "Privacy" for clarity
- Add fade-in animation for step transitions
- Add shadcn checkbox component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-11 16:09:42 +08:00
parent 7927903f32
commit 54bc00ce3f
12 changed files with 329 additions and 106 deletions

View file

@ -74,7 +74,7 @@ function createWindow() {
width: 1200,
height: 800,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 16, y: 12 },
trafficLightPosition: { x: 16, y: 17 }, // Vertically centered in 48px header
webPreferences: {
preload: path.join(__dirname, '../preload/index.cjs'),
// Enable node integration for IPC

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './components/theme-provider'
import Layout from './pages/layout'
import HomePage from './pages/home'
import ChatPage from './pages/chat'
@ -48,5 +49,9 @@ export default function App() {
useOnboardingStore.getState().initForceFlag()
}, [])
return <RouterProvider router={router} />
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<RouterProvider router={router} />
</ThemeProvider>
)
}

View file

@ -0,0 +1,46 @@
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,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu"
import { useTheme } from "./theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<HugeiconsIcon
icon={Sun03Icon}
className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<HugeiconsIcon
icon={Moon02Icon}
className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<HugeiconsIcon icon={Sun03Icon} className="size-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<HugeiconsIcon icon={Moon02Icon} className="size-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<HugeiconsIcon icon={ComputerIcon} className="size-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -3,7 +3,7 @@ import { HugeiconsIcon } from '@hugeicons/react'
import { Tick02Icon } from '@hugeicons/core-free-icons'
const steps = [
{ label: 'Permissions' },
{ label: 'Privacy' },
{ label: 'Provider' },
{ label: 'Connect' },
{ label: 'Try it' },

View file

@ -0,0 +1,68 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "multica-theme",
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
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
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View file

@ -6,6 +6,7 @@ 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 { StepDots } from './step-dots'
import { useOnboardingStore } from '../../../stores/onboarding'
function statusVariant(
@ -137,15 +138,18 @@ export default function ConnectStep({ onNext, onBack }: ConnectStepProps) {
</div>
)}
<div className="flex justify-end gap-2">
{!hasToken && (
<Button size="lg" variant="ghost" onClick={onNext}>
Skip
<div className="flex items-center justify-between">
<StepDots />
<div className="flex gap-2">
{!hasToken && (
<Button size="lg" variant="ghost" onClick={onNext}>
Skip
</Button>
)}
<Button size="lg" onClick={onNext} disabled={!isRunning}>
Continue
</Button>
)}
<Button size="lg" onClick={onNext} disabled={!isRunning}>
Continue
</Button>
</div>
</div>
</div>
</div>

View file

@ -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 (
<div className="h-full flex">
{/* Left column — main content, centered both axes */}
<div className="flex-1 flex items-center justify-center px-12 py-8">
<div className="max-w-md w-full space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Privacy & trust
</h1>
<p className="text-sm text-muted-foreground">
Multica works locally on your machine. Review what it accesses
and toggle each item to acknowledge.
</p>
</div>
<div className="h-full flex items-center justify-center px-6 py-8">
<div className="w-full max-w-md space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Privacy & trust
</h1>
<p className="text-sm text-muted-foreground">
Multica works locally on your machine. Here's what it can do.
</p>
</div>
<div className="space-y-3">
{acknowledgementItems.map((item) => (
<AcknowledgementItem
key={item.key}
icon={item.icon}
title={item.title}
description={item.description}
checked={acknowledgements[item.key]}
onCheckedChange={(checked) =>
setAcknowledgement(item.key, checked)
}
{/* Capabilities card */}
<div className="rounded-xl border border-border bg-card divide-y divide-border">
{capabilities.map((item) => (
<div key={item.title} className="flex items-start gap-3 p-4">
<div className="mt-0.5 flex items-center justify-center size-8 rounded-lg bg-muted shrink-0">
<HugeiconsIcon
icon={item.icon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{item.description}
</p>
</div>
</div>
))}
</div>
{/* Trust note */}
<div className="rounded-lg bg-muted/50 px-4 py-3">
<p className="text-sm text-muted-foreground">
Everything stays on your machine. We have no servers and can't see your data.
</p>
</div>
<Separator />
{/* Footer: dots on left, checkbox + button on right */}
<div className="flex items-center justify-between pt-4">
<StepDots />
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={agreed}
onCheckedChange={(checked) => setAgreed(checked === true)}
/>
))}
</div>
<div className="flex justify-end">
<Button
size="lg"
onClick={onNext}
disabled={!allAcknowledged}
>
I understand
</label>
<Button size="sm" onClick={onNext} disabled={!agreed}>
Continue
</Button>
</div>
</div>
</div>
{/* Right column — privacy panel */}
<div className="flex-1 flex items-center justify-center bg-muted/30 px-12 py-8">
<div className="max-w-sm">
<PrivacyPanel />
</div>
</div>
</div>
)
}

View file

@ -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) {
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex justify-end">
<div className="flex items-center justify-between">
<StepDots />
<Button
size="lg"
onClick={onNext}

View file

@ -0,0 +1,30 @@
import { cn } from "@multica/ui/lib/utils"
import { useOnboardingStore } from "../../../stores/onboarding"
const TOTAL_STEPS = 4
export function StepDots() {
const currentStep = useOnboardingStore((s) => s.currentStep)
return (
<div className="flex items-center gap-1.5">
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const step = i + 1 // steps are 1-based (1, 2, 3, 4)
const isActive = step === currentStep
const isCompleted = step < currentStep
return (
<div
key={step}
className={cn(
"size-1.5 rounded-full transition-colors",
isActive && "bg-foreground",
isCompleted && "bg-foreground/50",
!isActive && !isCompleted && "bg-muted-foreground/30"
)}
/>
)
})}
</div>
)
}

View file

@ -4,6 +4,7 @@ import { ChatView } from '@multica/ui/components/chat-view'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft02Icon } from '@hugeicons/core-free-icons'
import { SamplePrompt } from '../../../components/onboarding/sample-prompt'
import { StepDots } from './step-dots'
import { useLocalChat } from '../../../hooks/use-local-chat'
const samplePrompts = [
@ -80,7 +81,8 @@ export default function TryItStep({ onComplete, onBack }: TryItStepProps) {
))}
</div>
<div className="flex justify-end">
<div className="flex items-center justify-between">
<StepDots />
<Button size="lg" onClick={onComplete}>
Open Multica
</Button>

View file

@ -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 (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS */}
{/* Draggable title bar region for macOS - same height as main header */}
<header
className="shrink-0 h-8"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="shrink-0 h-12"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<main className="flex-1 overflow-auto">
<main
key={currentStep}
className="flex-1 overflow-auto animate-in fade-in duration-300"
>
<WelcomeStep onStart={nextStep} />
</main>
</div>
)
);
}
const stepLabel = steps[currentStep - 1];
const totalSteps = steps.length;
return (
<div className="h-dvh flex flex-col bg-background">
{/* Draggable title bar region for macOS + stepper */}
<header
className="shrink-0 px-6 pt-3 pb-2"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* Spacer for traffic lights */}
<div className="h-5" />
<Stepper currentStep={currentStep} />
<header className="shrink-0 h-12 flex items-center pr-4">
{/* Left: Draggable area for traffic lights */}
<div
className="w-20 h-full shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
{/* Brand */}
<div className="flex items-center gap-2 shrink-0">
<MulticaIcon className="size-4 text-muted-foreground/70" />
<span className="text-sm tracking-wide font-brand">Multica</span>
</div>
{/* Center: Step indicator */}
<div className="flex-1 flex justify-center">
<span className="text-sm text-muted-foreground">
{stepLabel} ({currentStep}/{totalSteps})
</span>
</div>
{/* Right: Theme toggle */}
<div className="shrink-0">
<ModeToggle />
</div>
</header>
{/* Step content */}
<main className="flex-1 overflow-auto">
<main
key={currentStep}
className="flex-1 overflow-auto animate-in fade-in duration-300"
>
{currentStep === 1 && <PermissionsStep onNext={nextStep} />}
{currentStep === 2 && <SetupStep onNext={nextStep} onBack={prevStep} />}
{currentStep === 3 && <ConnectStep onNext={nextStep} onBack={prevStep} />}
{currentStep === 4 && <TryItStep onComplete={handleComplete} onBack={prevStep} />}
{currentStep === 3 && (
<ConnectStep onNext={nextStep} onBack={prevStep} />
)}
{currentStep === 4 && (
<TryItStep onComplete={handleComplete} onBack={prevStep} />
)}
</main>
</div>
)
);
}

View file

@ -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 (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }