diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 54135b35..51003d2f 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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() diff --git a/apps/desktop/src/renderer/src/components/device-list.tsx b/apps/desktop/src/renderer/src/components/device-list.tsx index 6c208476..c1911151 100644 --- a/apps/desktop/src/renderer/src/components/device-list.tsx +++ b/apps/desktop/src/renderer/src/components/device-list.tsx @@ -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} > - + {refreshing ? ( + + ) : ( + + )} Refresh diff --git a/apps/desktop/src/renderer/src/hooks/use-devices.ts b/apps/desktop/src/renderer/src/hooks/use-devices.ts index 1e93a72b..082f8921 100644 --- a/apps/desktop/src/renderer/src/hooks/use-devices.ts +++ b/apps/desktop/src/renderer/src/hooks/use-devices.ts @@ -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 revokeDevice: (deviceId: string) => Promise } @@ -24,8 +29,10 @@ export interface UseDevicesReturn { export function useDevices(): UseDevicesReturn { const [devices, setDevices] = useState([]) 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 => { 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 } } diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 58f750cf..59f366ff 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -169,7 +169,7 @@ export default function Layout() { {/* Configuration */} - Configuration + Agent Configuration {configNavItems.map((item) => { const isActive = location.pathname.startsWith(item.path) 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 81831024..35af37f1 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,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(null) + const [switchingId, setSwitchingId] = useState(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 */}
- {isActive &&
} + {isSwitching ? ( + + ) : isActive ? ( +
+ ) : null}
diff --git a/apps/desktop/src/renderer/src/stores/provider.ts b/apps/desktop/src/renderer/src/stores/provider.ts index 8935b24c..ab447316 100644 --- a/apps/desktop/src/renderer/src/stores/provider.ts +++ b/apps/desktop/src/renderer/src/stores/provider.ts @@ -14,7 +14,7 @@ interface ProviderStore { // Actions fetch: () => Promise - 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 } @@ -82,28 +82,39 @@ export const useProviderStore = create()((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 } } }, diff --git a/package.json b/package.json index 3a629fad..95a4c31a 100644 --- a/package.json +++ b/package.json @@ -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\"",