refactor(desktop): add silent option to provider store and improve devices

- Add silent option to setProvider to suppress toast notifications
- Improve device list with better state handling
- Update onboarding setup step with silent provider switch
- Minor UI tweaks in layout and App components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-12 11:10:08 +08:00
parent eb4e1f57b1
commit 3fd6e10c86
7 changed files with 90 additions and 25 deletions

View file

@ -12,6 +12,7 @@ import ChannelsPage from './pages/channels'
import CronsPage from './pages/crons'
import OnboardingPage from './pages/onboarding'
import { useOnboardingStore } from './stores/onboarding'
import { useHubStore } from './stores/hub'
import { useProviderStore } from './stores/provider'
import { useChannelsStore } from './stores/channels'
import { useSkillsStore } from './stores/skills'
@ -71,7 +72,8 @@ export default function App() {
hydrateOnboardingState()
// Prefetch global data at app startup
// Initialize hub and prefetch global data at app startup
useHubStore.getState().init()
useProviderStore.getState().fetch()
useChannelsStore.getState().fetch()
useSkillsStore.getState().fetch()

View file

@ -89,7 +89,7 @@ function DeviceItem({
}
export function DeviceList() {
const { devices, loading, refresh, revokeDevice } = useDevices()
const { devices, loading, refreshing, refresh, revokeDevice } = useDevices()
if (loading) {
return null
@ -117,8 +117,13 @@ export function DeviceList() {
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={refresh}
disabled={refreshing}
>
<RotateCw className="size-3" />
{refreshing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RotateCw className="size-3" />
)}
Refresh
</Button>
</div>

View file

@ -1,4 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { toast } from '@multica/ui/components/ui/sonner'
// Minimum loading time for user perception (ms)
const MIN_LOADING_TIME = 600
export interface DeviceMeta {
userAgent?: string
@ -17,6 +21,7 @@ export interface DeviceEntry {
export interface UseDevicesReturn {
devices: DeviceEntry[]
loading: boolean
refreshing: boolean
refresh: () => Promise<void>
revokeDevice: (deviceId: string) => Promise<boolean>
}
@ -24,8 +29,10 @@ export interface UseDevicesReturn {
export function useDevices(): UseDevicesReturn {
const [devices, setDevices] = useState<DeviceEntry[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(async () => {
// Initial fetch (silent, no toast)
const fetchDevices = useCallback(async () => {
try {
const list = await window.electronAPI?.hub.listDevices()
setDevices((list as DeviceEntry[]) ?? [])
@ -36,33 +43,62 @@ export function useDevices(): UseDevicesReturn {
}
}, [])
// Manual refresh (with feedback)
const refresh = useCallback(async () => {
setRefreshing(true)
const startTime = Date.now()
try {
const list = await window.electronAPI?.hub.listDevices()
// Ensure minimum loading time for user perception
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
setDevices((list as DeviceEntry[]) ?? [])
toast.success('Device list refreshed')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to refresh devices', { description: message })
console.error('Failed to refresh devices:', err)
} finally {
setRefreshing(false)
}
}, [])
const revokeDevice = useCallback(async (deviceId: string): Promise<boolean> => {
try {
const result = await window.electronAPI?.hub.revokeDevice(deviceId)
if (result?.ok) {
setDevices((prev) => prev.filter((d) => d.deviceId !== deviceId))
toast.success('Device removed')
return true
}
toast.error('Failed to remove device')
return false
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to remove device', { description: message })
console.error('Failed to revoke device:', err)
return false
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
fetchDevices()
}, [fetchDevices])
// Subscribe to device list changes pushed from main process
// Subscribe to device list changes pushed from main process (silent refresh)
useEffect(() => {
window.electronAPI?.hub.onDevicesChanged(() => {
refresh()
fetchDevices()
})
return () => {
window.electronAPI?.hub.offDevicesChanged()
}
}, [refresh])
}, [fetchDevices])
return { devices, loading, refresh, revokeDevice }
return { devices, loading, refreshing, refresh, revokeDevice }
}

View file

@ -169,7 +169,7 @@ export default function Layout() {
{/* Configuration */}
<SidebarGroup>
<SidebarGroupLabel>Configuration</SidebarGroupLabel>
<SidebarGroupLabel>Agent Configuration</SidebarGroupLabel>
<SidebarMenu className="space-y-0.5">
{configNavItems.map((item) => {
const isActive = location.pathname.startsWith(item.path)

View file

@ -7,7 +7,7 @@ import {
HoverCardTrigger,
} from '@multica/ui/components/ui/hover-card'
import { Link } from '@multica/ui/components/ui/link'
import { ChevronLeft, HelpCircle } from 'lucide-react'
import { ChevronLeft, HelpCircle, Loader2 } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import { useProviderStore } from '../../../stores/provider'
import { ApiKeyDialog } from '../../../components/api-key-dialog'
@ -31,6 +31,7 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) {
const [oauthDialogOpen, setOauthDialogOpen] = useState(false)
const [selectedProvider, setSelectedProvider] =
useState<ProviderStatus | null>(null)
const [switchingId, setSwitchingId] = useState<string | null>(null)
const hasActiveProvider = current?.available === true
@ -48,8 +49,10 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) {
}
const handleSelect = async (provider: ProviderStatus) => {
if (provider.available) {
await setProvider(provider.id)
if (provider.available && !switchingId) {
setSwitchingId(provider.id)
await setProvider(provider.id, undefined, { silent: true })
setSwitchingId(null)
}
}
@ -95,6 +98,7 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) {
key={provider.id}
provider={provider}
isActive={Boolean(current?.available && current.provider === provider.id)}
isSwitching={switchingId === provider.id}
onSelect={() => handleSelect(provider)}
onConfigure={() => handleConfigure(provider)}
/>
@ -154,11 +158,13 @@ export default function SetupStep({ onNext, onBack }: SetupStepProps) {
function ProviderRow({
provider,
isActive,
isSwitching,
onSelect,
onConfigure,
}: {
provider: ProviderStatus
isActive: boolean
isSwitching: boolean
onSelect: () => void
onConfigure: () => void
}) {
@ -193,11 +199,16 @@ function ProviderRow({
{/* Radio indicator */}
<div
className={cn(
'size-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors',
'size-4 rounded-full flex items-center justify-center shrink-0 transition-colors',
isSwitching ? '' : 'border-2',
isActive ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{isActive && <div className="size-2 rounded-full bg-primary" />}
{isSwitching ? (
<Loader2 className="size-4 animate-spin text-primary" />
) : isActive ? (
<div className="size-2 rounded-full bg-primary" />
) : null}
</div>
<div>

View file

@ -14,7 +14,7 @@ interface ProviderStore {
// Actions
fetch: () => Promise<void>
setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }>
setProvider: (providerId: string, modelId?: string, options?: { silent?: boolean }) => Promise<{ ok: boolean; error?: string }>
refresh: () => Promise<void>
}
@ -82,28 +82,39 @@ export const useProviderStore = create<ProviderStore>()((set, get) => ({
}
},
setProvider: async (providerId: string, modelId?: string) => {
setProvider: async (providerId: string, modelId?: string, options?: { silent?: boolean }) => {
set({ error: null })
try {
const result = await window.electronAPI.provider.set(providerId, modelId)
if (result.ok) {
// Refresh to update current status
await get().refresh()
// Quick refresh without minimum delay for setProvider
const [providerList, currentInfo] = await Promise.all([
window.electronAPI.provider.list(),
window.electronAPI.provider.current(),
])
set({ providers: providerList, current: currentInfo })
// Find provider name for toast
const provider = get().providers.find(p => p.id === providerId)
toast.success(`Switched to ${provider?.name ?? providerId}`)
if (!options?.silent) {
const provider = providerList.find(p => p.id === providerId)
toast.success(`Switched to ${provider?.name ?? providerId}`)
}
return { ok: true }
} else {
set({ error: result.error ?? 'Unknown error' })
toast.error('Failed to switch provider', { description: result.error })
if (!options?.silent) {
toast.error('Failed to switch provider', { description: result.error })
}
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
toast.error('Failed to switch provider', { description: message })
if (!options?.silent) {
toast.error('Failed to switch provider', { description: message })
}
return { ok: false, error: message }
}
},

View file

@ -13,7 +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:desktop:onboarding": "pnpm --filter @multica/desktop dev:onboarding",
"dev:gateway": "pnpm --filter @multica/gateway dev",
"dev:web": "pnpm --filter @multica/web dev",
"dev:all": "concurrently \"pnpm dev:gateway\" \"pnpm dev:web\"",