From bc9b7d6fc5c9ee8d78bf9884043b91830f4cb2b3 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:49:52 +0800 Subject: [PATCH] refactor(desktop): restructure pages and consolidate UI components - Rename channels.tsx to clients.tsx - Remove standalone skills.tsx and tools.tsx pages - Add agent/ directory for agent-related pages - Update layout and navigation structure - Add tabs component to ui package - Update fonts and global styles Co-Authored-By: Claude Opus 4.5 --- apps/desktop/.env.example | 23 + apps/desktop/src/main/electron-env.d.ts | 11 + apps/desktop/src/main/ipc/hub.ts | 4 +- apps/desktop/src/main/ipc/skills.ts | 4 +- apps/desktop/src/renderer/index.html | 3 + apps/desktop/src/renderer/src/App.tsx | 18 +- .../renderer/src/components/skill-list.tsx | 189 ++---- .../src/renderer/src/components/tool-list.tsx | 192 +++--- .../src/renderer/src/pages/agent/profile.tsx | 358 ++++++++++ .../src/renderer/src/pages/agent/skills.tsx | 29 + .../src/renderer/src/pages/agent/tools.tsx | 29 + .../src/pages/{channels.tsx => clients.tsx} | 156 ++++- apps/desktop/src/renderer/src/pages/home.tsx | 621 +----------------- .../desktop/src/renderer/src/pages/layout.tsx | 233 ++++--- .../desktop/src/renderer/src/pages/skills.tsx | 31 - apps/desktop/src/renderer/src/pages/tools.tsx | 31 - apps/web/app/layout.tsx | 21 +- packages/ui/src/components/ui/tabs.tsx | 82 +++ packages/ui/src/styles/fonts.ts | 14 +- packages/ui/src/styles/globals.css | 40 +- 20 files changed, 1051 insertions(+), 1038 deletions(-) create mode 100644 apps/desktop/.env.example create mode 100644 apps/desktop/src/renderer/src/pages/agent/profile.tsx create mode 100644 apps/desktop/src/renderer/src/pages/agent/skills.tsx create mode 100644 apps/desktop/src/renderer/src/pages/agent/tools.tsx rename apps/desktop/src/renderer/src/pages/{channels.tsx => clients.tsx} (53%) delete mode 100644 apps/desktop/src/renderer/src/pages/skills.tsx delete mode 100644 apps/desktop/src/renderer/src/pages/tools.tsx create mode 100644 packages/ui/src/components/ui/tabs.tsx diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example new file mode 100644 index 00000000..25b5317f --- /dev/null +++ b/apps/desktop/.env.example @@ -0,0 +1,23 @@ +# ============================================================================= +# Multica Desktop Environment Configuration +# ============================================================================= +# +# Copy this file to create environment-specific configs: +# .env.development - Local development (npm run dev) +# .env.staging - Test environment (npm run build --mode staging) +# .env.production - Production release (npm run build) +# +# Variable naming convention: +# MAIN_VITE_* - Available in main process only +# RENDERER_VITE_* - Available in renderer process only +# VITE_* - Available in all processes +# +# ============================================================================= + +# Gateway WebSocket server URL +# Used for Hub <-> Gateway communication +MAIN_VITE_GATEWAY_URL=http://localhost:3000 + +# Multica API server URL +# Used for HTTP API requests (auth, updates, etc.) +MAIN_VITE_MULTICA_URL=http://localhost:3001 diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index e0c7c3ab..37b80d1d 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -1,5 +1,16 @@ /// +// Environment variables loaded from .env files +// See: .env.example, .env.development, .env.staging, .env.production +interface ImportMetaEnv { + readonly MAIN_VITE_GATEWAY_URL: string + readonly MAIN_VITE_MULTICA_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + declare namespace NodeJS { interface ProcessEnv { /** diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index 6654ccb8..45d3580e 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -39,7 +39,7 @@ export async function initializeHub(): Promise { return } - const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000' + const gatewayUrl = import.meta.env.MAIN_VITE_GATEWAY_URL safeLog(`[Desktop] Initializing Hub, connecting to Gateway: ${gatewayUrl}`) hub = new Hub(gatewayUrl) @@ -62,7 +62,7 @@ export async function initializeHub(): Promise { */ function getHub(): Hub { if (!hub) { - const gatewayUrl = process.env['GATEWAY_URL'] ?? 'http://localhost:3000' + const gatewayUrl = import.meta.env.MAIN_VITE_GATEWAY_URL safeLog(`[Desktop] Creating Hub, connecting to Gateway: ${gatewayUrl}`) hub = new Hub(gatewayUrl) } diff --git a/apps/desktop/src/main/ipc/skills.ts b/apps/desktop/src/main/ipc/skills.ts index fe724dcd..368c1d09 100644 --- a/apps/desktop/src/main/ipc/skills.ts +++ b/apps/desktop/src/main/ipc/skills.ts @@ -102,10 +102,10 @@ export function registerSkillsIpcHandlers(): void { id: skill.id, name: skill.name, description: skill.description, - version: '1.0.0', // Skills don't have version in current implementation + version: '1.0.0', enabled: skill.eligible, source: skill.source as 'bundled' | 'global' | 'profile', - triggers: [`/${skill.id}`], // Default trigger is / + triggers: [`/${skill.id}`], })) console.log(`[IPC] skills:list - Returning ${skills.length} skills from agent`) diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 655326da..07c2a84e 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -9,6 +9,9 @@ diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 51003d2f..33177e2d 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -6,9 +6,10 @@ import { Toaster } from './components/toaster' import Layout from './pages/layout' import HomePage from './pages/home' import ChatPage from './pages/chat' -import ToolsPage from './pages/tools' -import SkillsPage from './pages/skills' -import ChannelsPage from './pages/channels' +import ProfilePage from './pages/agent/profile' +import SkillsPage from './pages/agent/skills' +import ToolsPage from './pages/agent/tools' +import ClientsPage from './pages/clients' import CronsPage from './pages/crons' import OnboardingPage from './pages/onboarding' import { useOnboardingStore } from './stores/onboarding' @@ -43,9 +44,10 @@ const router = createHashRouter([ ), }, { path: 'chat', element: }, - { path: 'tools', element: }, - { path: 'skills', element: }, - { path: 'channels', element: }, + { path: 'agent/profile', element: }, + { path: 'agent/skills', element: }, + { path: 'agent/tools', element: }, + { path: 'clients', element: }, { path: 'crons', element: }, ], }, @@ -56,14 +58,12 @@ export default function App() { const setCompleted = useOnboardingStore((s) => s.setCompleted) useEffect(() => { - // Load onboarding state from file system async function hydrateOnboardingState() { try { const completed = await window.electronAPI.appState.getOnboardingCompleted() setCompleted(completed) } catch (err) { console.error('[App] Failed to load onboarding state:', err) - // Default to false if load fails setCompleted(false) } finally { setIsHydrated(true) @@ -72,7 +72,6 @@ export default function App() { hydrateOnboardingState() - // Initialize hub and prefetch global data at app startup useHubStore.getState().init() useProviderStore.getState().fetch() useChannelsStore.getState().fetch() @@ -81,7 +80,6 @@ export default function App() { useCronJobsStore.getState().fetch() }, [setCompleted]) - // Show nothing while loading onboarding state to prevent flash if (!isHydrated) { return ( diff --git a/apps/desktop/src/renderer/src/components/skill-list.tsx b/apps/desktop/src/renderer/src/components/skill-list.tsx index 64210948..58f573b5 100644 --- a/apps/desktop/src/renderer/src/components/skill-list.tsx +++ b/apps/desktop/src/renderer/src/components/skill-list.tsx @@ -1,22 +1,14 @@ import { useState } from 'react' import { Button } from '@multica/ui/components/ui/button' -import { Badge } from '@multica/ui/components/ui/badge' -import { Switch } from '@multica/ui/components/ui/switch' import { - RotateCw, - Loader2, - CheckCircle, - X, -} from 'lucide-react' + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@multica/ui/components/ui/collapsible' +import { RotateCw, Loader2, ChevronRight } from 'lucide-react' +import { cn } from '@multica/ui/lib/utils' import type { SkillInfo, SkillSource } from '../stores/skills' -// Source badge colors -const SOURCE_COLORS: Record = { - bundled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', - global: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', - profile: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', -} - // Source section titles const SOURCE_TITLES: Record = { bundled: 'Built-in Skills', @@ -36,23 +28,21 @@ export function SkillList({ skills, loading, error, - onToggleSkill, onRefresh, }: SkillListProps) { - // Track toggling state for individual skills - const [togglingSkills, setTogglingSkills] = useState>(new Set()) + // Track which skills are expanded + const [expandedSkills, setExpandedSkills] = useState>(new Set()) - const handleToggleSkill = async (skillId: string) => { - setTogglingSkills((prev) => new Set(prev).add(skillId)) - try { - await onToggleSkill(skillId) - } finally { - setTogglingSkills((prev) => { - const next = new Set(prev) + const toggleSkill = (skillId: string) => { + setExpandedSkills((prev) => { + const next = new Set(prev) + if (next.has(skillId)) { next.delete(skillId) - return next - }) - } + } else { + next.add(skillId) + } + return next + }) } // Group skills by source @@ -68,8 +58,7 @@ export function SkillList({ if (loading && skills.length === 0) { return (
- - Loading skills... +
) } @@ -78,20 +67,20 @@ export function SkillList({
{/* Header */}
-
- {skills.filter((s) => s.enabled).length} of {skills.length} skills enabled -
+

+ {skills.length} skill{skills.length !== 1 && 's'} available +

@@ -110,94 +99,53 @@ export function SkillList({ if (sourceSkills.length === 0) return null return ( -
-

- - {SOURCE_TITLES[source]} - ({sourceSkills.length}) -

-
- {sourceSkills.map((skill) => { - const isToggling = togglingSkills.has(skill.id) +
+ {/* Section header */} +

{SOURCE_TITLES[source]}

+ + {/* Skills card */} +
+ {sourceSkills.map((skill, index) => { + const isExpanded = expandedSkills.has(skill.id) + const isLast = index === sourceSkills.length - 1 return ( -
toggleSkill(skill.id)} > - {/* Left: Name + Description */} -
-
- {skill.name} - - /{skill.id} - - - {skill.source} - -
-

- {skill.description} -

- {skill.triggers.length > 0 && ( -
- {skill.triggers.slice(0, 3).map((trigger) => ( - - {trigger} - - ))} - {skill.triggers.length > 3 && ( - - +{skill.triggers.length - 3} more - - )} -
+ - - {/* Center: Status */} -
-
- {skill.enabled ? ( - - ) : ( - + > + {skill.name} + + {skill.triggers[0] || `/${skill.id}`} + + + {skill.version} + + - {skill.enabled ? 'Enabled' : 'Disabled'} - -
-
- - {/* Right: Toggle */} -
- {isToggling && ( - - )} - handleToggleSkill(skill.id)} - disabled={isToggling} /> -
-
+ + +
+ {skill.description || 'No description'} +
+
+ ) })}
@@ -207,15 +155,10 @@ export function SkillList({ {/* Empty state */} {skills.length === 0 && !loading && ( -
-

No skills found.

+
+ No skills found.
)} - - {/* Note about persistence */} -

- Changes are saved automatically. Restart Agent session to apply skill changes. -

) } diff --git a/apps/desktop/src/renderer/src/components/tool-list.tsx b/apps/desktop/src/renderer/src/components/tool-list.tsx index 9b2501fd..1417cfd8 100644 --- a/apps/desktop/src/renderer/src/components/tool-list.tsx +++ b/apps/desktop/src/renderer/src/components/tool-list.tsx @@ -1,18 +1,23 @@ import { useState, useMemo } from 'react' import { Switch } from '@multica/ui/components/ui/switch' import { Button } from '@multica/ui/components/ui/button' +import { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from '@multica/ui/components/ui/collapsible' import { RotateCw, FolderOpen, Code, Globe, Brain, - ChevronDown, - ChevronUp, + ChevronRight, Loader2, Clock, Users, } from 'lucide-react' +import { cn } from '@multica/ui/lib/utils' import type { ToolInfo } from '../stores/tools' // Group display names @@ -26,6 +31,17 @@ const GROUP_NAMES: Record = { other: 'Other', } +// Group descriptions +const GROUP_DESCRIPTIONS: Record = { + fs: 'Read, write, and manage files', + runtime: 'Execute code and commands', + web: 'Fetch and interact with web content', + memory: 'Store and recall information', + subagent: 'Delegate tasks to sub-agents', + cron: 'Schedule recurring tasks', + other: 'Miscellaneous tools', +} + // Group icons const GROUP_ICONS: Record = { fs: FolderOpen, @@ -54,13 +70,14 @@ export function ToolList({ }: ToolListProps) { // Compute groups from tools const groups = useMemo(() => { - const groupIds = [...new Set(tools.map(t => t.group))] - return groupIds.map(id => ({ + const groupIds = [...new Set(tools.map((t) => t.group))] + return groupIds.map((id) => ({ id, name: GROUP_NAMES[id] || id, - tools: tools.filter(t => t.group === id), - enabledCount: tools.filter(t => t.group === id && t.enabled).length, - totalCount: tools.filter(t => t.group === id).length, + description: GROUP_DESCRIPTIONS[id] || '', + tools: tools.filter((t) => t.group === id), + enabledCount: tools.filter((t) => t.group === id && t.enabled).length, + totalCount: tools.filter((t) => t.group === id).length, })) }, [tools]) @@ -100,31 +117,29 @@ export function ToolList({ if (loading && tools.length === 0) { return (
- - Loading tools... +
) } return (
- {/* Header: Refresh button */} -
-
+ {/* Header */} +
+

{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled -

- +

@@ -138,86 +153,83 @@ export function ToolList({ )} {/* Tool groups */} -
- {groups.map((group) => { - const isExpanded = expandedGroups.has(group.id) - const GroupIcon = GROUP_ICONS[group.id] || Code + {groups.map((group) => { + const isExpanded = expandedGroups.has(group.id) + const GroupIcon = GROUP_ICONS[group.id] || Code - return ( -
- {/* Group header */} - + + + +
+ {group.tools.map((tool, index) => { + const isToggling = togglingTools.has(tool.name) + const isLast = index === group.tools.length - 1 - {/* Group tools */} - {isExpanded && ( -
- {group.tools.map((tool) => { - const isToggling = togglingTools.has(tool.name) - - return ( -
-
-
- - {tool.name} - - {!tool.enabled && ( - - disabled - + return ( +
+
+ {tool.name} + {tool.description && ( +

+ {tool.description} +

)}
- {tool.description && ( -

- {tool.description} -

- )} +
+ {isToggling && ( + + )} + handleToggleTool(tool.name)} + disabled={isToggling} + /> +
-
- {isToggling && ( - - )} - handleToggleTool(tool.name)} - disabled={isToggling} - /> -
-
- ) - })} -
- )} -
- ) - })} -
+ ) + })} +
+
+
+ +
+ ) + })} - {/* Note about persistence */} -

- Changes are saved automatically and apply to the running Agent immediately. + {/* Note */} +

+ Changes apply immediately to the running Agent.

) diff --git a/apps/desktop/src/renderer/src/pages/agent/profile.tsx b/apps/desktop/src/renderer/src/pages/agent/profile.tsx new file mode 100644 index 00000000..63211676 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/agent/profile.tsx @@ -0,0 +1,358 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { Button } from '@multica/ui/components/ui/button' +import { Input } from '@multica/ui/components/ui/input' +import { Textarea } from '@multica/ui/components/ui/textarea' +import { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxEmpty, +} from '@multica/ui/components/ui/combobox' +import { + Loader2, + Check, + AlertCircle, + ChevronDown, +} from 'lucide-react' +import { ApiKeyDialog } from '../../components/api-key-dialog' +import { OAuthDialog } from '../../components/oauth-dialog' +import { useProviderStore } from '../../stores/provider' +import { toast } from '@multica/ui/components/ui/sonner' +import { cn } from '@multica/ui/lib/utils' + +export default function ProfilePage() { + const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore() + + const [profileLoading, setProfileLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [name, setName] = useState('') + const [userContent, setUserContent] = useState('') + const [hasChanges, setHasChanges] = useState(false) + const [originalName, setOriginalName] = useState('') + const [originalUserContent, setOriginalUserContent] = useState('') + + const [providerDropdownOpen, setProviderDropdownOpen] = useState(false) + const [switching, setSwitching] = useState(false) + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + const [selectedProvider, setSelectedProvider] = useState<{ + id: string + name: string + authMethod: 'api-key' | 'oauth' + loginCommand?: string + } | null>(null) + const dropdownRef = useRef(null) + + + useEffect(() => { + loadProfile() + }, []) + + useEffect(() => { + setHasChanges(name !== originalName || userContent !== originalUserContent) + }, [name, userContent, originalName, originalUserContent]) + + 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]) + + const loadProfile = async () => { + setProfileLoading(true) + try { + const data = await window.electronAPI.profile.get() + const loadedName = data.name ?? '' + const loadedUserContent = data.userContent ?? '' + setName(loadedName) + setUserContent(loadedUserContent) + setOriginalName(loadedName) + setOriginalUserContent(loadedUserContent) + } catch (err) { + console.error('Failed to load profile:', err) + toast.error('Failed to load profile') + } finally { + setProfileLoading(false) + } + } + + const handleSaveProfile = useCallback(async () => { + setSaving(true) + try { + await window.electronAPI.profile.updateName(name) + await window.electronAPI.profile.updateUser(userContent) + setOriginalName(name) + setOriginalUserContent(userContent) + setHasChanges(false) + toast.success('Profile saved') + } catch (err) { + console.error('Failed to save profile:', err) + toast.error('Failed to save profile') + } finally { + setSaving(false) + } + }, [name, userContent]) + + // Keep ref to latest save function + const saveRef = useRef(handleSaveProfile) + saveRef.current = handleSaveProfile + + // Show/hide persistent toast for unsaved changes + useEffect(() => { + const toastId = 'unsaved-changes' + if (hasChanges) { + toast('Unsaved changes', { + id: toastId, + duration: Infinity, + action: { + label: 'Save', + onClick: () => saveRef.current() + } + }) + } else { + toast.dismiss(toastId) + } + }, [hasChanges]) + + const handleProviderClick = async (p: typeof providers[0]) => { + 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) { + toast.error('Failed to switch provider') + } + } + + const handleModelSelect = async (model: string) => { + if (!model || model === current?.model || !current?.provider) return + setSwitching(true) + const result = await setProvider(current.provider, model) + setSwitching(false) + if (!result.ok) { + toast.error('Failed to switch model') + } + } + + if (profileLoading || providerLoading) { + return ( +
+ +
+ ) + } + + const currentProvider = providers.find(p => p.id === current?.provider) + + return ( +
+
+ {/* Page Header */} +
+

Profile

+

+ Configure your agent's identity and the model that powers it. +

+
+ + {/* Model Section */} +
+
+

Model

+

AI model for your agent

+
+ +
+ {/* Provider Row */} +
+
+
Provider
+
LLM API connection
+
+
+ + + {providerDropdownOpen && ( +
+ {providers.map((p) => ( + + ))} +
+ )} +
+
+ + {/* Model Row - with Combobox */} + {currentProvider && ( +
+
+
Model
+
Select or enter model ID
+
+ { + if (value) handleModelSelect(value) + }} + disabled={switching} + > + + + No models found + + {(model) => ( + + {model} + + )} + + + +
+ )} +
+ + {!current?.available && ( +

+ Select a provider and add your API key to enable your agent. +

+ )} +
+ + {/* Identity Section */} +
+
+

Identity

+

How your agent presents itself

+
+ +
+
+
+
Agent Name
+
Personalize interactions
+
+ setName(e.target.value)} + placeholder="My Assistant" + className="h-8 w-48 text-sm" + /> +
+
+
+ + {/* Personalization Section */} +
+
+

Personalization

+

Help the agent understand you better

+
+ +