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:
parent
eb4e1f57b1
commit
3fd6e10c86
7 changed files with 90 additions and 25 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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\"",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue