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 <noreply@anthropic.com>
This commit is contained in:
parent
ee8e0a67c9
commit
bc9b7d6fc5
20 changed files with 1051 additions and 1038 deletions
23
apps/desktop/.env.example
Normal file
23
apps/desktop/.env.example
Normal file
|
|
@ -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
|
||||
11
apps/desktop/src/main/electron-env.d.ts
vendored
11
apps/desktop/src/main/electron-env.d.ts
vendored
|
|
@ -1,5 +1,16 @@
|
|||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
// 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 {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export async function initializeHub(): Promise<void> {
|
|||
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<void> {
|
|||
*/
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 /<skill-id>
|
||||
triggers: [`/${skill.id}`],
|
||||
}))
|
||||
|
||||
console.log(`[IPC] skills:list - Returning ${skills.length} skills from agent`)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
/* Use system fonts for proper CJK punctuation support */
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-geist-mono: "Geist Mono", ui-monospace, monospace;
|
||||
--font-brand: "Playfair Display", serif;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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: <ChatPage /> },
|
||||
{ path: 'tools', element: <ToolsPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
{ path: 'channels', element: <ChannelsPage /> },
|
||||
{ path: 'agent/profile', element: <ProfilePage /> },
|
||||
{ path: 'agent/skills', element: <SkillsPage /> },
|
||||
{ path: 'agent/tools', element: <ToolsPage /> },
|
||||
{ path: 'clients', element: <ClientsPage /> },
|
||||
{ path: 'crons', element: <CronsPage /> },
|
||||
],
|
||||
},
|
||||
|
|
@ -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 (
|
||||
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
|
||||
|
|
|
|||
|
|
@ -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<SkillSource, string> = {
|
||||
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<SkillSource, string> = {
|
||||
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<Set<string>>(new Set())
|
||||
// Track which skills are expanded
|
||||
const [expandedSkills, setExpandedSkills] = useState<Set<string>>(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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading skills...</span>
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -78,20 +67,20 @@ export function SkillList({
|
|||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{skills.filter((s) => s.enabled).length} of {skills.length} skills enabled
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skills.length} skill{skills.length !== 1 && 's'} available
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="gap-1.5"
|
||||
className="gap-1.5 h-8"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-4" />
|
||||
<RotateCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
|
|
@ -110,94 +99,53 @@ export function SkillList({
|
|||
if (sourceSkills.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={source} className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
source === 'bundled'
|
||||
? 'bg-blue-500'
|
||||
: source === 'global'
|
||||
? 'bg-green-500'
|
||||
: 'bg-purple-500'
|
||||
}`}
|
||||
/>
|
||||
{SOURCE_TITLES[source]}
|
||||
<span className="text-xs">({sourceSkills.length})</span>
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{sourceSkills.map((skill) => {
|
||||
const isToggling = togglingSkills.has(skill.id)
|
||||
<div key={source}>
|
||||
{/* Section header */}
|
||||
<h3 className="text-sm font-medium mb-3">{SOURCE_TITLES[source]}</h3>
|
||||
|
||||
{/* Skills card */}
|
||||
<div className="rounded-lg border bg-card">
|
||||
{sourceSkills.map((skill, index) => {
|
||||
const isExpanded = expandedSkills.has(skill.id)
|
||||
const isLast = index === sourceSkills.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
<Collapsible
|
||||
key={skill.id}
|
||||
className="flex items-center justify-between px-4 py-3 rounded-lg border hover:bg-muted/30 transition-colors"
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleSkill(skill.id)}
|
||||
>
|
||||
{/* Left: Name + Description */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
<code className="text-xs text-muted-foreground font-mono">
|
||||
/{skill.id}
|
||||
</code>
|
||||
<Badge variant="secondary" className={`text-xs ${SOURCE_COLORS[skill.source]}`}>
|
||||
{skill.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{skill.description}
|
||||
</p>
|
||||
{skill.triggers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{skill.triggers.slice(0, 3).map((trigger) => (
|
||||
<code
|
||||
key={trigger}
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
>
|
||||
{trigger}
|
||||
</code>
|
||||
))}
|
||||
{skill.triggers.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{skill.triggers.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left',
|
||||
!isLast && !isExpanded && 'border-b'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Status */}
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<div
|
||||
className={`flex items-center gap-1 ${
|
||||
skill.enabled
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{skill.enabled ? (
|
||||
<CheckCircle className="size-4" />
|
||||
) : (
|
||||
<X className="size-4" />
|
||||
>
|
||||
<span className="text-sm font-medium flex-1">{skill.name}</span>
|
||||
<code className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono">
|
||||
{skill.triggers[0] || `/${skill.id}`}
|
||||
</code>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{skill.version}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform flex-shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{skill.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isToggling && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={() => handleToggleSkill(skill.id)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground p-4',
|
||||
!isLast && 'border-b'
|
||||
)}
|
||||
>
|
||||
{skill.description || 'No description'}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -207,15 +155,10 @@ export function SkillList({
|
|||
|
||||
{/* Empty state */}
|
||||
{skills.length === 0 && !loading && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No skills found.</p>
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
No skills found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note about persistence */}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Changes are saved automatically. Restart Agent session to apply skill changes.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
other: 'Other',
|
||||
}
|
||||
|
||||
// Group descriptions
|
||||
const GROUP_DESCRIPTIONS: Record<string, string> = {
|
||||
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<string, typeof FolderOpen> = {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading tools...</span>
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header: Refresh button */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tools.filter((t) => t.enabled).length} of {tools.length} tools enabled
|
||||
</div>
|
||||
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="gap-1.5"
|
||||
disabled={loading}
|
||||
className="gap-1.5 h-8"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-4" />
|
||||
<RotateCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
|
|
@ -138,86 +153,83 @@ export function ToolList({
|
|||
)}
|
||||
|
||||
{/* Tool groups */}
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div
|
||||
key={group.id}
|
||||
className="border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GroupIcon className="size-5 text-muted-foreground" />
|
||||
<span className="font-medium">{group.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.enabledCount}/{group.totalCount} enabled
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{/* Section header */}
|
||||
<div className="mb-3 flex items-start gap-2">
|
||||
<GroupIcon className="size-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{group.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.enabledCount}/{group.totalCount} enabled
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools card */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => toggleGroup(group.id)}>
|
||||
<div className="rounded-lg border bg-card">
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/50 transition-colors text-left">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{group.description}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform flex-shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{group.tools.map((tool, index) => {
|
||||
const isToggling = togglingTools.has(tool.name)
|
||||
const isLast = index === group.tools.length - 1
|
||||
|
||||
{/* Group tools */}
|
||||
{isExpanded && (
|
||||
<div className="divide-y">
|
||||
{group.tools.map((tool) => {
|
||||
const isToggling = togglingTools.has(tool.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-center justify-between px-4 py-3 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono font-medium">
|
||||
{tool.name}
|
||||
</code>
|
||||
{!tool.enabled && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
disabled
|
||||
</span>
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors',
|
||||
!isLast && 'border-b'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="text-sm font-mono">{tool.name}</code>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{isToggling && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => handleToggleTool(tool.name)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isToggling && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => handleToggleTool(tool.name)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Note about persistence */}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Changes are saved automatically and apply to the running Agent immediately.
|
||||
{/* Note */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changes apply immediately to the running Agent.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
358
apps/desktop/src/renderer/src/pages/agent/profile.tsx
Normal file
358
apps/desktop/src/renderer/src/pages/agent/profile.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentProvider = providers.find(p => p.id === current?.provider)
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-medium">Profile</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your agent's identity and the model that powers it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Section */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-medium">Model</h2>
|
||||
<p className="text-xs text-muted-foreground">AI model for your agent</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
{/* Provider Row */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b" ref={dropdownRef}>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Provider</div>
|
||||
<div className="text-xs text-muted-foreground">LLM API connection</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setProviderDropdownOpen(!providerDropdownOpen)}
|
||||
disabled={switching}
|
||||
>
|
||||
{current?.available ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="size-3 text-yellow-500" />
|
||||
)}
|
||||
<span>{current?.providerName ?? 'Select'}</span>
|
||||
<ChevronDown className={cn(
|
||||
'size-3.5 text-muted-foreground transition-transform',
|
||||
providerDropdownOpen && 'rotate-180'
|
||||
)} />
|
||||
</Button>
|
||||
|
||||
{providerDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-10 bg-popover border border-border rounded-lg shadow-lg p-1.5 min-w-[200px]">
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded text-left text-sm transition-colors',
|
||||
p.id === current?.provider
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50',
|
||||
!p.available && 'opacity-50'
|
||||
)}
|
||||
onClick={() => handleProviderClick(p)}
|
||||
disabled={switching}
|
||||
>
|
||||
<span className={cn(
|
||||
'size-2 rounded-full flex-shrink-0',
|
||||
p.available ? 'bg-green-500' : 'bg-muted-foreground/40'
|
||||
)} />
|
||||
<span>{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Row - with Combobox */}
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Model</div>
|
||||
<div className="text-xs text-muted-foreground">Select or enter model ID</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={currentProvider?.models ?? []}
|
||||
value={current?.model ?? ''}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleModelSelect(value)
|
||||
}}
|
||||
disabled={switching}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Select model"
|
||||
className="w-48 h-8 text-sm font-mono"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!current?.available && (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
||||
Select a provider and add your API key to enable your agent.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Identity Section */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-medium">Identity</h2>
|
||||
<p className="text-xs text-muted-foreground">How your agent presents itself</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-sm font-medium mb-1">Agent Name</div>
|
||||
<div className="text-xs text-muted-foreground">Personalize interactions</div>
|
||||
</div>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Assistant"
|
||||
className="h-8 w-48 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Personalization Section */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-medium">Personalization</h2>
|
||||
<p className="text-xs text-muted-foreground">Help the agent understand you better</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={userContent}
|
||||
onChange={(e) => setUserContent(e.target.value)}
|
||||
placeholder="- I'm a frontend developer - I prefer TypeScript - Please respond in Chinese"
|
||||
className="min-h-[120px] font-mono text-sm"
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
29
apps/desktop/src/renderer/src/pages/agent/skills.tsx
Normal file
29
apps/desktop/src/renderer/src/pages/agent/skills.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useSkillsStore } from '../../stores/skills'
|
||||
import { SkillList } from '../../components/skill-list'
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-medium">Skills</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skills are modular capabilities that expand what your agent can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<SkillList
|
||||
skills={skills}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleSkill={toggleSkill}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
apps/desktop/src/renderer/src/pages/agent/tools.tsx
Normal file
29
apps/desktop/src/renderer/src/pages/agent/tools.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useToolsStore } from '../../stores/tools'
|
||||
import { ToolList } from '../../components/tool-list'
|
||||
|
||||
export default function ToolsPage() {
|
||||
const { tools, loading, error, toggleTool, refresh } = useToolsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-lg font-medium">Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toggle tools to control what your agent can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ToolList
|
||||
tools={tools}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleTool={toggleTool}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,17 @@ import {
|
|||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { Input } from '@multica/ui/components/ui/input'
|
||||
import { Badge } from '@multica/ui/components/ui/badge'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@multica/ui/components/ui/tabs'
|
||||
import { QrCode, Radio, Smartphone } from 'lucide-react'
|
||||
import { useChannelsStore } from '../stores/channels'
|
||||
import { useHubStore, selectPrimaryAgent } from '../stores/hub'
|
||||
import { ConnectionQRCode } from '../components/qr-code'
|
||||
import { DeviceList } from '../components/device-list'
|
||||
|
||||
/** Status badge color mapping */
|
||||
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
|
|
@ -152,31 +162,133 @@ function TelegramCard() {
|
|||
)
|
||||
}
|
||||
|
||||
export default function ChannelsPage() {
|
||||
function ChannelsTab() {
|
||||
const { loading, error } = useChannelsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Channels</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Channels let you talk to your agent from other platforms like Telegram or Slack. Connect one to chat with your agent anywhere.
|
||||
</p>
|
||||
</div>
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>
|
||||
}
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : (
|
||||
<TelegramCard />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">{error}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect messaging platforms to chat with your agent.
|
||||
</p>
|
||||
<TelegramCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MulticaAppTab() {
|
||||
const { hubInfo, agents } = useHubStore()
|
||||
const primaryAgent = selectPrimaryAgent(agents)
|
||||
const [qrCodeExpanded, setQrCodeExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan to connect from your phone. Manage authorized devices.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* QR Code Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Scan to Connect</CardTitle>
|
||||
<CardDescription>
|
||||
Open Multica on your phone and scan.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQrCodeExpanded(!qrCodeExpanded)}
|
||||
>
|
||||
<QrCode className="size-4 mr-1.5" />
|
||||
{qrCodeExpanded ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
{qrCodeExpanded ? (
|
||||
<ConnectionQRCode
|
||||
gateway={hubInfo?.url ?? 'http://localhost:3000'}
|
||||
hubId={hubInfo?.hubId ?? 'unknown'}
|
||||
agentId={primaryAgent?.id}
|
||||
expirySeconds={30}
|
||||
size={160}
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<QrCode className="size-12 text-muted-foreground/40" />
|
||||
<span className="text-sm text-muted-foreground">Click to show QR code</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device List Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Authorized Devices</CardTitle>
|
||||
<CardDescription>
|
||||
Devices you've approved to access your agent.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DeviceList />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ClientsPage() {
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Clients</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access your agent from anywhere. Connect via third-party platforms or the Multica mobile app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="channels" className="flex-1">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="channels" className="gap-2">
|
||||
<Radio className="size-4" />
|
||||
Channels
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="app" className="gap-2">
|
||||
<Smartphone className="size-4" />
|
||||
Multica App
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<ChannelsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="app">
|
||||
<MulticaAppTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,126 +1,22 @@
|
|||
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 {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Check,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
QrCode,
|
||||
Pencil,
|
||||
Plug,
|
||||
Code,
|
||||
Share,
|
||||
Clock,
|
||||
Brain,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
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 { useHubStore } 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()
|
||||
const { loading } = useHubStore()
|
||||
const { current, loading: providerLoading } = useProviderStore()
|
||||
|
||||
// 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">
|
||||
|
|
@ -132,42 +28,17 @@ export default function HomePage() {
|
|||
)
|
||||
}
|
||||
|
||||
// Refresh all capabilities
|
||||
const refreshCapabilities = async () => {
|
||||
setCapabilitiesRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
useSkillsStore.getState().refresh({ silent: true }),
|
||||
useToolsStore.getState().refresh({ silent: true }),
|
||||
useChannelsStore.getState().refresh({ silent: true }),
|
||||
useCronJobsStore.getState().refresh({ silent: true }),
|
||||
])
|
||||
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
|
||||
const capabilitySummary = `${skillStats.enabled} skills, ${enabledTools} tools, ${connectedChannels} channels, ${cronCount} scheduled tasks`
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6 h-full">
|
||||
{/* 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>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="container shrink-0 px-6 pt-6">
|
||||
{/* 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.</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 */}
|
||||
{/* Status Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-1 mt-4">
|
||||
<span className="relative flex size-2">
|
||||
{agentReady ? (
|
||||
|
|
@ -188,470 +59,42 @@ export default function HomePage() {
|
|||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
)}>
|
||||
{agentReady
|
||||
? 'Your agent is running'
|
||||
: 'Configure LLM provider to start'}
|
||||
{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.'}
|
||||
: 'Go to Agent settings to configure your LLM provider.'}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => navigate('/chat')}
|
||||
disabled={!agentReady}
|
||||
>
|
||||
Start Chat
|
||||
<ArrowRight className="size-4" />
|
||||
</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>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => navigate('/chat')}
|
||||
disabled={!agentReady}
|
||||
>
|
||||
<span>{agentName || 'Unnamed Agent'}</span>
|
||||
<Pencil 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 ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle 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>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground transition-transform',
|
||||
providerDropdownOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
Start Chat
|
||||
<ArrowRight className="size-4" />
|
||||
</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>
|
||||
{!agentReady && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
onClick={() => navigate('/agent/profile')}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
Configure Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border mb-6" />
|
||||
|
||||
{/* Section 3: Capabilities (Collapsible) */}
|
||||
<Collapsible open={capabilitiesOpen} onOpenChange={setCapabilitiesOpen} className="mb-6">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Brain className="size-4 text-muted-foreground" />
|
||||
Your agent currently has {capabilitySummary}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={refreshCapabilities}
|
||||
disabled={capabilitiesRefreshing}
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{capabilitiesRefreshing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh status</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<CollapsibleTrigger className="group flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{capabilitiesOpen ? 'Hide' : 'Details'}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 transition-transform',
|
||||
capabilitiesOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Plug 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
|
||||
<ArrowRight 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">
|
||||
<Code 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
|
||||
<ArrowRight 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">
|
||||
<Share 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
|
||||
<ArrowRight 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">
|
||||
<Clock 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
|
||||
<ArrowRight 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">Control from Anywhere</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQrCodeExpanded(!qrCodeExpanded)}
|
||||
>
|
||||
<QrCode className="size-4 mr-1.5" />
|
||||
{qrCodeExpanded ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground pl-0.5">
|
||||
Open Multica Web on your phone and scan. Operate your computer and use your agent 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"
|
||||
>
|
||||
<QrCode 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 self-stretch" />
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,33 @@
|
|||
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { MulticaIcon } from '@multica/ui/components/multica-icon'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from '@multica/ui/components/ui/collapsible'
|
||||
import {
|
||||
Home,
|
||||
MessageSquare,
|
||||
Puzzle,
|
||||
Wrench,
|
||||
Radio,
|
||||
Users,
|
||||
Clock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Bot,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
|
|
@ -31,23 +38,30 @@ import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
|||
import { UpdateNotification } from '../components/update-notification'
|
||||
|
||||
const mainNavItems = [
|
||||
{ path: '/', label: 'Home', icon: Home },
|
||||
{ path: '/', label: 'Home', icon: Home, exact: true },
|
||||
{ path: '/chat', label: 'Chat', icon: MessageSquare },
|
||||
]
|
||||
|
||||
const configNavItems = [
|
||||
{ path: '/skills', label: 'Skills', icon: Puzzle },
|
||||
{ path: '/tools', label: 'Tools', icon: Wrench },
|
||||
{ path: '/channels', label: 'Channels', icon: Radio },
|
||||
{ path: '/crons', label: 'Crons', icon: Clock },
|
||||
const agentSubItems = [
|
||||
{ path: '/agent/profile', label: 'Profile' },
|
||||
{ path: '/agent/skills', label: 'Skills' },
|
||||
{ path: '/agent/tools', label: 'Tools' },
|
||||
]
|
||||
|
||||
const bottomNavItems = [
|
||||
{ path: '/clients', label: 'Clients', icon: Users },
|
||||
{ path: '/crons', label: 'Scheduled Tasks', icon: Clock },
|
||||
]
|
||||
|
||||
// All nav items for header lookup
|
||||
const allNavItems = [...mainNavItems, ...configNavItems]
|
||||
const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact?: boolean }> = [
|
||||
...mainNavItems,
|
||||
{ path: '/agent', label: 'Agent', icon: Bot },
|
||||
...bottomNavItems,
|
||||
]
|
||||
|
||||
function NavigationButtons() {
|
||||
const navigate = useNavigate()
|
||||
// useLocation() triggers re-render on route change so we can re-evaluate history state
|
||||
useLocation()
|
||||
|
||||
const historyIdx = window.history.state?.idx ?? 0
|
||||
|
|
@ -84,27 +98,29 @@ function MainHeader() {
|
|||
const location = useLocation()
|
||||
const needsTrafficLightSpace = state === 'collapsed' || isMobile
|
||||
|
||||
// Find current page info
|
||||
const currentPage = allNavItems.find((item) =>
|
||||
item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
)
|
||||
const currentPage = allNavItems.find((item) => {
|
||||
if (item.exact) return location.pathname === item.path
|
||||
return location.pathname.startsWith(item.path)
|
||||
})
|
||||
|
||||
return (
|
||||
<header className="h-12 shrink-0 flex items-center px-4">
|
||||
{/* Drag placeholder for traffic lights when sidebar is collapsed */}
|
||||
<header
|
||||
className="h-12 shrink-0 flex items-center px-4"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full shrink-0 transition-[width] duration-200 ease-linear',
|
||||
needsTrafficLightSpace ? 'w-16' : 'w-0'
|
||||
)}
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<SidebarTrigger />
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<SidebarTrigger
|
||||
className={cn(needsTrafficLightSpace && 'text-muted-foreground')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center: Current page */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
{currentPage && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
|
|
@ -114,95 +130,120 @@ function MainHeader() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Theme toggle */}
|
||||
<ModeToggle />
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const isAgentActive = location.pathname.startsWith('/agent')
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<SidebarProvider className="flex-1 overflow-hidden">
|
||||
<Sidebar>
|
||||
{/* Traffic light area with navigation */}
|
||||
<SidebarHeader
|
||||
className="h-12 shrink-0 flex items-center"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<NavigationButtons />
|
||||
</SidebarHeader>
|
||||
<SidebarHeader
|
||||
className="h-12 shrink-0 flex items-center"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<NavigationButtons />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<MulticaIcon bordered noSpin />
|
||||
<span className="text-sm font-brand">Multica</span>
|
||||
</div>
|
||||
<SidebarContent>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<MulticaIcon bordered noSpin />
|
||||
<span className="text-sm font-brand">Multica</span>
|
||||
</div>
|
||||
|
||||
{/* Main navigation */}
|
||||
<SidebarGroup>
|
||||
<SidebarMenu className="space-y-0.5">
|
||||
{mainNavItems.map((item) => {
|
||||
const isActive = item.path === '/'
|
||||
? location.pathname === '/'
|
||||
: location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu className="space-y-0.5">
|
||||
{/* Main nav items */}
|
||||
{mainNavItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Agent collapsible */}
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger
|
||||
render={<SidebarMenuButton isActive={isAgentActive} />}
|
||||
>
|
||||
<Bot
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isAgentActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>Agent</span>
|
||||
<ChevronDown className="ml-auto size-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{agentSubItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.path}>
|
||||
<SidebarMenuSubButton
|
||||
render={<NavLink to={item.path} />}
|
||||
isActive={location.pathname === item.path}
|
||||
>
|
||||
{item.label}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
|
||||
{/* Configuration */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Agent Configuration</SidebarGroupLabel>
|
||||
<SidebarMenu className="space-y-0.5">
|
||||
{configNavItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
{/* Bottom nav items */}
|
||||
{bottomNavItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path)
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<NavLink to={item.path}>
|
||||
<SidebarMenuButton isActive={isActive}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-4 transition-colors',
|
||||
!isActive && 'text-muted-foreground/50 group-hover/menu-button:text-foreground'
|
||||
)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset className="overflow-hidden">
|
||||
<MainHeader />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-hidden min-h-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
<SidebarInset className="overflow-hidden">
|
||||
<MainHeader />
|
||||
<main className="flex-1 overflow-hidden min-h-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
||||
<DeviceConfirmDialog />
|
||||
<UpdateNotification />
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { useSkillsStore } from '../stores/skills'
|
||||
import { SkillList } from '../components/skill-list'
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { skills, loading, error, toggleSkill, refresh } = useSkillsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Skills</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skills are modular capabilities that expand what your agent can do. You can also ask your agent to create new skills for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<SkillList
|
||||
skills={skills}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleSkill={toggleSkill}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { useToolsStore } from '../stores/tools'
|
||||
import { ToolList } from '../components/tool-list'
|
||||
|
||||
export default function ToolsPage() {
|
||||
const { tools, loading, error, toggleTool, refresh } = useToolsStore()
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="container flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-medium">Tools</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tools are actions your agent can perform, like reading files, searching the web, or running code. Toggle them to control what your agent can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolList
|
||||
tools={tools}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleTool={toggleTool}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,23 @@
|
|||
import type { Metadata } from "next";
|
||||
import "@multica/ui/fonts";
|
||||
import { Geist_Mono, Inter, Playfair_Display } from "next/font/google";
|
||||
import "@multica/ui/globals.css";
|
||||
import { ThemeProvider } from "@multica/ui/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { ServiceWorkerRegister } from "./sw-register";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
variable: "--font-brand",
|
||||
subsets: ["latin"],
|
||||
weight: ["400"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Multica",
|
||||
description: "Distributed AI agent framework",
|
||||
|
|
@ -24,8 +37,10 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased h-dvh flex flex-col">
|
||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistMono.variable} ${playfair.variable} font-sans antialiased h-dvh flex flex-col`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
|
|
|||
82
packages/ui/src/components/ui/tabs.tsx
Normal file
82
packages/ui/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"gap-2 group/tabs flex data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("text-sm flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
|
@ -1,11 +1,5 @@
|
|||
// Unified font imports for Web and Desktop
|
||||
// Using fontsource for consistent cross-platform font loading
|
||||
|
||||
// Geist Sans - Primary UI font
|
||||
import '@fontsource/geist-sans/400.css'
|
||||
import '@fontsource/geist-sans/500.css'
|
||||
import '@fontsource/geist-sans/600.css'
|
||||
import '@fontsource/geist-sans/700.css'
|
||||
// Font imports for Desktop (Electron)
|
||||
// Web uses next/font/google instead
|
||||
|
||||
// Geist Mono - Code font
|
||||
import '@fontsource/geist-mono/400.css'
|
||||
|
|
@ -13,5 +7,5 @@ import '@fontsource/geist-mono/500.css'
|
|||
import '@fontsource/geist-mono/600.css'
|
||||
import '@fontsource/geist-mono/700.css'
|
||||
|
||||
// Playfair Display - Brand font
|
||||
import '@fontsource-variable/playfair-display'
|
||||
// Note: Geist Sans removed - Desktop uses system fonts for CJK support
|
||||
// Note: Playfair Display loaded via Google Fonts in index.html
|
||||
|
|
|
|||
|
|
@ -18,24 +18,14 @@
|
|||
* TYPOGRAPHY
|
||||
* =============================================================================
|
||||
*
|
||||
* Font Stack (loaded via @fontsource, works across Web + Desktop):
|
||||
* Fonts are loaded via CSS variables injected by each app:
|
||||
* - Web (Next.js): Uses next/font/google in layout.tsx
|
||||
* - Desktop (Electron): Uses @fontsource packages
|
||||
*
|
||||
* | Font | Variable | Usage |
|
||||
* |---------------------------|---------------|------------------------------|
|
||||
* | Geist Sans | font-sans | Primary UI text, body copy |
|
||||
* | Geist Mono | font-mono | Code, technical values |
|
||||
* | Playfair Display Variable | font-brand | Brand name "Multica" only |
|
||||
*
|
||||
* Why Geist?
|
||||
* - Created by Vercel, optimized for UI/developer tools
|
||||
* - Excellent legibility at small sizes (12-14px)
|
||||
* - Neutral, professional appearance — not "AI-ish" or trendy
|
||||
* - Variable font = smaller bundle, flexible weights
|
||||
*
|
||||
* Why Playfair Display for brand?
|
||||
* - Contrast with Geist creates clear hierarchy
|
||||
* - Serif adds warmth/personality to otherwise minimal UI
|
||||
* - Used ONLY for "Multica" text — nowhere else
|
||||
* Variables used:
|
||||
* - --font-sans: Primary UI text (Inter/system font)
|
||||
* - --font-geist-mono: Code and technical values
|
||||
* - --font-brand: Brand name "Multica" only (Playfair Display)
|
||||
*
|
||||
* =============================================================================
|
||||
* COLOR SYSTEM
|
||||
|
|
@ -93,9 +83,9 @@
|
|||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||
--font-brand: 'Playfair Display Variable', serif;
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-brand: var(--font-brand);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -135,14 +125,6 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
/* =========================================================================
|
||||
* FONTS — unified across Web (Next.js) and Desktop (Electron/Vite)
|
||||
* Loaded via @fontsource packages in packages/ui/src/styles/fonts.ts
|
||||
* ========================================================================= */
|
||||
--font-sans: 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||
--font-brand: 'Playfair Display Variable', serif;
|
||||
|
||||
/* =========================================================================
|
||||
* COLORS — Light mode
|
||||
* Using OKLCH for perceptual uniformity across the palette
|
||||
|
|
@ -275,7 +257,7 @@
|
|||
}
|
||||
|
||||
@utility container {
|
||||
@apply w-full max-w-4xl mx-auto;
|
||||
@apply w-full max-w-5xl mx-auto;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue