From 272cabf3fa6dae3ef6458bbf7e864776714a9fdc Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:39:03 +0800 Subject: [PATCH] feat(desktop): redesign home page with capabilities dashboard - Add collapsible capabilities section showing skills, tools, channels, and scheduled tasks counts - Add AI brain icon to represent agent capabilities - Add refresh button with tooltip to refresh all capabilities - Add desktop-specific Toaster component (uses local ThemeProvider) - Show all capability counts even when zero - Change "View all" buttons to outline style for better distinction Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/renderer/src/App.tsx | 8 + .../src/renderer/src/components/toaster.tsx | 67 ++ apps/desktop/src/renderer/src/pages/home.tsx | 659 +++++++++++++++++- 3 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/toaster.tsx diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index b0b1f698..95cc772b 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom' import { ThemeProvider } from './components/theme-provider' import { TooltipProvider } from '@multica/ui/components/ui/tooltip' +import { Toaster } from './components/toaster' import Layout from './pages/layout' import HomePage from './pages/home' import ChatPage from './pages/chat' @@ -13,6 +14,9 @@ import OnboardingPage from './pages/onboarding' import { useOnboardingStore } from './stores/onboarding' import { useProviderStore } from './stores/provider' import { useChannelsStore } from './stores/channels' +import { useSkillsStore } from './stores/skills' +import { useToolsStore } from './stores/tools' +import { useCronJobsStore } from './stores/cron-jobs' function OnboardingGuard({ children }: { children: React.ReactNode }) { const completed = useOnboardingStore((s) => s.completed) @@ -51,12 +55,16 @@ export default function App() { // Prefetch global data at app startup useProviderStore.getState().fetch() useChannelsStore.getState().fetch() + useSkillsStore.getState().fetch() + useToolsStore.getState().fetch() + useCronJobsStore.getState().fetch() }, []) return ( + ) diff --git a/apps/desktop/src/renderer/src/components/toaster.tsx b/apps/desktop/src/renderer/src/components/toaster.tsx new file mode 100644 index 00000000..99782b86 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/toaster.tsx @@ -0,0 +1,67 @@ +import { Toaster as Sonner, type ToasterProps } from 'sonner' +import { HugeiconsIcon } from '@hugeicons/react' +import { + CheckmarkCircle02Icon, + InformationCircleIcon, + Alert02Icon, + MultiplicationSignCircleIcon, + Loading03Icon, +} from '@hugeicons/core-free-icons' +import { useTheme } from './theme-provider' + +export function Toaster(props: ToasterProps) { + const { resolvedTheme } = useTheme() + + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + '--normal-bg': 'var(--popover)', + '--normal-text': 'var(--popover-foreground)', + '--normal-border': 'var(--border)', + '--border-radius': 'var(--radius)', + } as React.CSSProperties + } + {...props} + /> + ) +} diff --git a/apps/desktop/src/renderer/src/pages/home.tsx b/apps/desktop/src/renderer/src/pages/home.tsx index c062d89d..98de475c 100644 --- a/apps/desktop/src/renderer/src/pages/home.tsx +++ b/apps/desktop/src/renderer/src/pages/home.tsx @@ -1,7 +1,660 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@multica/ui/components/ui/button' +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@multica/ui/components/ui/collapsible' +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from '@multica/ui/components/ui/tooltip' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Comment01Icon, + Loading03Icon, + ArrowDown01Icon, + Tick02Icon, + Alert02Icon, + ArrowRight01Icon, + QrCodeIcon, + Edit02Icon, + PlugIcon, + CodeIcon, + Share08Icon, + Time04Icon, + AiBrain01Icon, + ArrowReloadHorizontalIcon, +} from '@hugeicons/core-free-icons' +import { ConnectionQRCode } from '../components/qr-code' +import { DeviceList } from '../components/device-list' +import { AgentSettingsDialog } from '../components/agent-settings-dialog' +import { ApiKeyDialog } from '../components/api-key-dialog' +import { OAuthDialog } from '../components/oauth-dialog' +import { useHubStore, selectPrimaryAgent } from '../stores/hub' +import { useProviderStore } from '../stores/provider' +import { useChannelsStore } from '../stores/channels' +import { useSkillsStore, selectSkillStats } from '../stores/skills' +import { useToolsStore } from '../stores/tools' +import { useCronJobsStore } from '../stores/cron-jobs' +import { toast } from '@multica/ui/components/ui/sonner' +import { cn } from '@multica/ui/lib/utils' + export default function HomePage() { + const navigate = useNavigate() + const { hubInfo, agents, loading } = useHubStore() + const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore() + const { skills } = useSkillsStore() + const { tools } = useToolsStore() + const { states: channelStates } = useChannelsStore() + const { jobs: cronJobs } = useCronJobsStore() + + // Computed values + const skillStats = selectSkillStats(skills) + + const [capabilitiesOpen, setCapabilitiesOpen] = useState(false) + const [capabilitiesRefreshing, setCapabilitiesRefreshing] = useState(false) + + const [settingsOpen, setSettingsOpen] = useState(false) + const [agentName, setAgentName] = useState() + const [providerDropdownOpen, setProviderDropdownOpen] = useState(false) + const [switching, setSwitching] = useState(false) + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + const [qrCodeExpanded, setQrCodeExpanded] = useState(false) + const [selectedProvider, setSelectedProvider] = useState<{ + id: string + name: string + authMethod: 'api-key' | 'oauth' + loginCommand?: string + } | null>(null) + const dropdownRef = useRef(null) + + // Computed stats + const enabledTools = tools.filter(t => t.enabled).length + const connectedChannels = channelStates.filter(s => s.status === 'running').length + const cronCount = cronJobs.length + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setProviderDropdownOpen(false) + } + } + + if (providerDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [providerDropdownOpen]) + + // Load agent profile info + useEffect(() => { + loadAgentInfo() + }, []) + + // Reload agent info when settings dialog closes + useEffect(() => { + if (!settingsOpen) { + loadAgentInfo() + } + }, [settingsOpen]) + + const loadAgentInfo = async () => { + try { + const data = await window.electronAPI.profile.get() + setAgentName(data.name) + } catch (err) { + console.error('Failed to load agent info:', err) + } + } + + // Get the first agent + const primaryAgent = selectPrimaryAgent(agents) + + // Agent status: running if app is open, warning if no LLM provider + const isProviderAvailable = current?.available ?? false + const agentReady = !providerLoading && isProviderAvailable + + // Loading state (only while provider info is loading) + if (loading || providerLoading) { + return ( +
+
+ + Starting agent... +
+
+ ) + } + + // Refresh all capabilities + const refreshCapabilities = async () => { + setCapabilitiesRefreshing(true) + try { + await Promise.all([ + useSkillsStore.getState().refresh(), + useToolsStore.getState().refresh(), + useChannelsStore.getState().refresh(), + useCronJobsStore.getState().refresh(), + ]) + toast.success('Status refreshed') + } catch (err) { + // Individual store refresh errors are already toasted + console.error('[HomePage] Failed to refresh capabilities:', err) + } finally { + setCapabilitiesRefreshing(false) + } + } + + // Build capability summary (always show all, even if zero) + const capabilitySummary = `${skillStats.enabled} skills, ${enabledTools} tools, ${connectedChannels} channels, ${cronCount} scheduled tasks` + return ( -
- HomePage +
+ {/* Page Header */} +
+

Dashboard

+

Overview of your agent's status and capabilities.

+
+ + {/* Row 1: Status + Chat (Left) | Agent Settings (Right) */} +
+ {/* Left: Status + Chat */} +
+ {/* Status */} +
+ + {agentReady ? ( + <> + + + + ) : ( + <> + + + + )} + + + {agentReady + ? 'Your agent is running' + : 'Configure LLM provider to start'} + +
+ +

+ {agentReady + ? 'Ready to assist you. Start a conversation to get things done.' + : 'Select an LLM provider on the right to enable your agent.'} +

+ + +
+ + {/* Vertical Divider */} +
+ + {/* Right: Agent Settings (stacked vertically) */} +
+ {/* Agent Profile */} +
+ Agent Profile + +
+ + {/* LLM Provider */} +
+ LLM Provider + + + {/* Provider Dropdown */} + {providerDropdownOpen && ( +
+
+ {providers.map((p) => ( + + ))} +
+ + {/* Model List */} + {(() => { + const currentProvider = providers.find(p => p.id === current?.provider) + if (!currentProvider || currentProvider.models.length <= 1) return null + return ( +
+

+ Models +

+
+ {currentProvider.models.map((model) => ( + + ))} +
+
+ ) + })()} +
+ )} +
+
+
+ + {/* Divider */} +
+ + {/* Section 3: Capabilities (Collapsible) */} + + + + + Your agent has {capabilitySummary} + + { + e.stopPropagation() + refreshCapabilities() + }} + disabled={capabilitiesRefreshing} + className="p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50" + > + + + Refresh status + + + + {capabilitiesOpen ? 'Hide' : 'Details'} + + + + + + {/* Skills */} +
+
+
+ + Skills ({skillStats.enabled}) +
+ +
+ {skillStats.enabled > 0 ? ( +
+ {skills.filter(s => s.enabled).slice(0, 8).map((skill) => ( + + {skill.name} + + ))} + {skillStats.enabled > 8 && ( + + +{skillStats.enabled - 8} more + + )} +
+ ) : ( +

No skills enabled

+ )} +
+ + {/* Tools */} +
+
+
+ + Tools ({enabledTools}) +
+ +
+ {enabledTools > 0 ? ( +
+ {tools.filter(t => t.enabled).slice(0, 8).map((tool) => ( + + {tool.name} + + ))} + {enabledTools > 8 && ( + + +{enabledTools - 8} more + + )} +
+ ) : ( +

No tools enabled

+ )} +
+ + {/* Channels */} +
+
+
+ + Channels ({connectedChannels}) +
+ +
+ {connectedChannels > 0 ? ( +
+ {channelStates.filter(s => s.status === 'running').slice(0, 8).map((channel) => ( + + {channel.channelId}/{channel.accountId} + + ))} + {connectedChannels > 8 && ( + + +{connectedChannels - 8} more + + )} +
+ ) : ( +

No channels connected

+ )} +
+ + {/* Cron Jobs */} +
+
+
+ + Scheduled Tasks ({cronCount}) +
+ +
+ {cronCount > 0 ? ( +
+ {cronJobs.slice(0, 8).map((job) => ( + + {job.name} + + ))} + {cronCount > 8 && ( + + +{cronCount - 8} more + + )} +
+ ) : ( +

No scheduled tasks

+ )} +
+
+
+ + {/* Divider */} +
+ + {/* Section 4: Multi-Device Access */} +
+
+ {/* Left: Connect */} +
+
+

Remote Access

+ +
+

+ Scan with your phone to connect remotely. +

+ + {/* QR Code Container */} +
+ {qrCodeExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Vertical Divider */} +
+ + {/* Right: Authorized Devices */} +
+

Authorized Devices

+

+ Devices you've approved to access your agent. +

+
+ +
+
+
+
+ + {/* Dialogs */} + + + {selectedProvider && selectedProvider.authMethod === 'api-key' && ( + { + await refresh() + const result = await setProvider(selectedProvider.id) + if (!result.ok) { + console.error('Failed to switch provider:', result.error) + } + }} + /> + )} + + {selectedProvider && selectedProvider.authMethod === 'oauth' && ( + { + await refresh() + const result = await setProvider(selectedProvider.id) + if (!result.ok) { + console.error('Failed to switch provider:', result.error) + } + }} + /> + )}
) -} \ No newline at end of file +}