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 <noreply@anthropic.com>
This commit is contained in:
parent
d1d3cd0a9f
commit
272cabf3fa
3 changed files with 731 additions and 3 deletions
|
|
@ -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 (
|
||||
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="bottom-right" />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
|
|
|||
67
apps/desktop/src/renderer/src/components/toaster.tsx
Normal file
67
apps/desktop/src/renderer/src/components/toaster.tsx
Normal file
|
|
@ -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 (
|
||||
<Sonner
|
||||
theme={resolvedTheme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<HugeiconsIcon
|
||||
icon={CheckmarkCircle02Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 text-emerald-500"
|
||||
/>
|
||||
),
|
||||
info: (
|
||||
<HugeiconsIcon
|
||||
icon={InformationCircleIcon}
|
||||
strokeWidth={2}
|
||||
className="size-4 text-blue-500"
|
||||
/>
|
||||
),
|
||||
warning: (
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 text-amber-500"
|
||||
/>
|
||||
),
|
||||
error: (
|
||||
<HugeiconsIcon
|
||||
icon={MultiplicationSignCircleIcon}
|
||||
strokeWidth={2}
|
||||
className="size-4 text-red-500"
|
||||
/>
|
||||
),
|
||||
loading: (
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 text-muted-foreground animate-spin"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string | undefined>()
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-5 animate-spin" />
|
||||
<span>Starting agent...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
HomePage
|
||||
<div className="h-full flex flex-col p-6 overflow-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Overview of your agent's status and capabilities.</p>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Status + Chat (Left) | Agent Settings (Right) */}
|
||||
<div className="flex gap-8 mb-6">
|
||||
{/* Left: Status + Chat */}
|
||||
<div className="flex-1">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 mb-1 mt-4">
|
||||
<span className="relative flex size-2">
|
||||
{agentReady ? (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-green-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-yellow-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full size-2 bg-yellow-500" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'font-medium',
|
||||
agentReady
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
)}>
|
||||
{agentReady
|
||||
? 'Your agent is running'
|
||||
: 'Configure LLM provider to start'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{agentReady
|
||||
? 'Ready to assist you. Start a conversation to get things done.'
|
||||
: 'Select an LLM provider on the right to enable your agent.'}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => navigate('/chat')}
|
||||
disabled={!agentReady}
|
||||
>
|
||||
<HugeiconsIcon icon={Comment01Icon} className="size-5" />
|
||||
Start Chat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Vertical Divider */}
|
||||
<div className="w-px bg-border" />
|
||||
|
||||
{/* Right: Agent Settings (stacked vertically) */}
|
||||
<div className="flex-1 space-y-4">
|
||||
{/* Agent Profile */}
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground block mb-2">Agent Profile</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<span>{agentName || 'Unnamed Agent'}</span>
|
||||
<HugeiconsIcon icon={Edit02Icon} className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* LLM Provider */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<span className="text-sm text-muted-foreground block mb-2">LLM Provider</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
disabled={providerLoading || switching}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{current?.available ? (
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4 text-green-500" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Alert02Icon} className="size-4 text-yellow-500" />
|
||||
)}
|
||||
<span>{current?.providerName ?? 'Loading...'}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground text-xs font-mono">{current?.model ?? '-'}</span>
|
||||
</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform',
|
||||
providerDropdownOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Provider Dropdown */}
|
||||
{providerDropdownOpen && (
|
||||
<div className="absolute left-0 right-0 top-full mt-2 z-10 bg-popover border border-border rounded-lg shadow-lg p-2 max-h-[50vh] overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1.5 rounded text-left text-xs transition-colors',
|
||||
p.id === current?.provider
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'hover:bg-accent/50 border border-transparent',
|
||||
!p.available && 'opacity-60 hover:opacity-80'
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (!p.available) {
|
||||
setSelectedProvider({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authMethod: p.authMethod,
|
||||
loginCommand: p.loginCommand,
|
||||
})
|
||||
setProviderDropdownOpen(false)
|
||||
if (p.authMethod === 'oauth') {
|
||||
setOauthDialogOpen(true)
|
||||
} else {
|
||||
setApiKeyDialogOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSwitching(true)
|
||||
setProviderDropdownOpen(false)
|
||||
const result = await setProvider(p.id)
|
||||
setSwitching(false)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className={cn(
|
||||
'size-1.5 rounded-full flex-shrink-0',
|
||||
p.available ? 'bg-green-500' : 'bg-muted-foreground/50'
|
||||
)} />
|
||||
<span className="truncate font-medium">
|
||||
{p.id === 'claude-code' ? 'Claude Code' :
|
||||
p.id === 'openai-codex' ? 'Codex' :
|
||||
p.id === 'kimi-coding' ? 'Kimi' :
|
||||
p.id === 'anthropic' ? 'Anthropic' :
|
||||
p.id === 'openai' ? 'OpenAI' :
|
||||
p.id === 'openrouter' ? 'OpenRouter' :
|
||||
p.name.split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model List */}
|
||||
{(() => {
|
||||
const currentProvider = providers.find(p => p.id === current?.provider)
|
||||
if (!currentProvider || currentProvider.models.length <= 1) return null
|
||||
return (
|
||||
<div className="border-t border-border mt-2 pt-2">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider px-1 mb-1">
|
||||
Models
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{currentProvider.models.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1 rounded text-left text-xs transition-colors',
|
||||
model === current?.model
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'hover:bg-accent/50 text-muted-foreground'
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (model === current?.model) return
|
||||
setSwitching(true)
|
||||
setProviderDropdownOpen(false)
|
||||
const result = await setProvider(currentProvider.id, model)
|
||||
setSwitching(false)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch model:', result.error)
|
||||
}
|
||||
}}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className={cn(
|
||||
'size-1.5 rounded-full flex-shrink-0',
|
||||
model === current?.model ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
)} />
|
||||
<span className="font-mono truncate">{model}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border mb-6" />
|
||||
|
||||
{/* Section 3: Capabilities (Collapsible) */}
|
||||
<Collapsible open={capabilitiesOpen} onOpenChange={setCapabilitiesOpen} className="mb-6">
|
||||
<CollapsibleTrigger className="group flex items-center justify-between w-full text-left py-2">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<HugeiconsIcon icon={AiBrain01Icon} className="size-4 text-muted-foreground" />
|
||||
Your agent has {capabilitySummary}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshCapabilities()
|
||||
}}
|
||||
disabled={capabilitiesRefreshing}
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={capabilitiesRefreshing ? Loading03Icon : ArrowReloadHorizontalIcon}
|
||||
className={cn('size-4', capabilitiesRefreshing && 'animate-spin')}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh status</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{capabilitiesOpen ? 'Hide' : 'Details'}
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
capabilitiesOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="pt-4 space-y-4">
|
||||
{/* Skills */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<HugeiconsIcon icon={PlugIcon} className="size-4 text-muted-foreground" />
|
||||
<span>Skills ({skillStats.enabled})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => navigate('/skills')}
|
||||
>
|
||||
View all
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{skillStats.enabled > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skills.filter(s => s.enabled).slice(0, 8).map((skill) => (
|
||||
<span
|
||||
key={skill.id}
|
||||
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
|
||||
title={skill.name}
|
||||
>
|
||||
{skill.name}
|
||||
</span>
|
||||
))}
|
||||
{skillStats.enabled > 8 && (
|
||||
<span className="text-xs px-2 py-1 text-muted-foreground">
|
||||
+{skillStats.enabled - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No skills enabled</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<HugeiconsIcon icon={CodeIcon} className="size-4 text-muted-foreground" />
|
||||
<span>Tools ({enabledTools})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => navigate('/tools')}
|
||||
>
|
||||
View all
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{enabledTools > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tools.filter(t => t.enabled).slice(0, 8).map((tool) => (
|
||||
<span
|
||||
key={tool.name}
|
||||
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
|
||||
title={tool.description || tool.name}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
{enabledTools > 8 && (
|
||||
<span className="text-xs px-2 py-1 text-muted-foreground">
|
||||
+{enabledTools - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No tools enabled</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<HugeiconsIcon icon={Share08Icon} className="size-4 text-muted-foreground" />
|
||||
<span>Channels ({connectedChannels})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => navigate('/channels')}
|
||||
>
|
||||
View all
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{connectedChannels > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channelStates.filter(s => s.status === 'running').slice(0, 8).map((channel) => (
|
||||
<span
|
||||
key={`${channel.channelId}-${channel.accountId}`}
|
||||
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
|
||||
>
|
||||
{channel.channelId}/{channel.accountId}
|
||||
</span>
|
||||
))}
|
||||
{connectedChannels > 8 && (
|
||||
<span className="text-xs px-2 py-1 text-muted-foreground">
|
||||
+{connectedChannels - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No channels connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cron Jobs */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<HugeiconsIcon icon={Time04Icon} className="size-4 text-muted-foreground" />
|
||||
<span>Scheduled Tasks ({cronCount})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => navigate('/crons')}
|
||||
>
|
||||
View all
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{cronCount > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cronJobs.slice(0, 8).map((job) => (
|
||||
<span
|
||||
key={job.id}
|
||||
className="text-xs px-2 py-1 rounded-md bg-muted text-muted-foreground truncate max-w-[150px]"
|
||||
title={job.description || job.name}
|
||||
>
|
||||
{job.name}
|
||||
</span>
|
||||
))}
|
||||
{cronCount > 8 && (
|
||||
<span className="text-xs px-2 py-1 text-muted-foreground">
|
||||
+{cronCount - 8} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No scheduled tasks</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border mb-6" />
|
||||
|
||||
{/* Section 4: Multi-Device Access */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex gap-8 h-full">
|
||||
{/* Left: Connect */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">Remote Access</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQrCodeExpanded(!qrCodeExpanded)}
|
||||
>
|
||||
<HugeiconsIcon icon={QrCodeIcon} className="size-4 mr-1.5" />
|
||||
{qrCodeExpanded ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan with your phone to connect remotely.
|
||||
</p>
|
||||
|
||||
{/* QR Code Container */}
|
||||
<div className="flex-1 flex items-center justify-center mt-4">
|
||||
{qrCodeExpanded ? (
|
||||
<ConnectionQRCode
|
||||
gateway={hubInfo?.url ?? 'http://localhost:3000'}
|
||||
hubId={hubInfo?.hubId ?? 'unknown'}
|
||||
agentId={primaryAgent?.id}
|
||||
expirySeconds={30}
|
||||
size={140}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setQrCodeExpanded(true)}
|
||||
className="flex flex-col items-center justify-center gap-3 p-8 rounded-xl border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<HugeiconsIcon icon={QrCodeIcon} className="size-12 text-muted-foreground/40" />
|
||||
<span className="text-sm text-muted-foreground">Click to show QR code</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Divider */}
|
||||
<div className="w-px bg-border" />
|
||||
|
||||
{/* Right: Authorized Devices */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<h3 className="text-sm font-medium mb-2">Authorized Devices</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Devices you've approved to access your agent.
|
||||
</p>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DeviceList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AgentSettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
|
||||
{selectedProvider && selectedProvider.authMethod === 'api-key' && (
|
||||
<ApiKeyDialog
|
||||
open={apiKeyDialogOpen}
|
||||
onOpenChange={setApiKeyDialogOpen}
|
||||
providerId={selectedProvider.id}
|
||||
providerName={selectedProvider.name}
|
||||
onSuccess={async () => {
|
||||
await refresh()
|
||||
const result = await setProvider(selectedProvider.id)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider && selectedProvider.authMethod === 'oauth' && (
|
||||
<OAuthDialog
|
||||
open={oauthDialogOpen}
|
||||
onOpenChange={setOauthDialogOpen}
|
||||
providerId={selectedProvider.id}
|
||||
providerName={selectedProvider.name}
|
||||
loginCommand={selectedProvider.loginCommand}
|
||||
onSuccess={async () => {
|
||||
await refresh()
|
||||
const result = await setProvider(selectedProvider.id)
|
||||
if (!result.ok) {
|
||||
console.error('Failed to switch provider:', result.error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue