diff --git a/apps/desktop/src/renderer/src/components/cron-job-list.tsx b/apps/desktop/src/renderer/src/components/cron-job-list.tsx index d01a2613..26f8d172 100644 --- a/apps/desktop/src/renderer/src/components/cron-job-list.tsx +++ b/apps/desktop/src/renderer/src/components/cron-job-list.tsx @@ -11,7 +11,7 @@ import { CancelCircleIcon, AlertCircleIcon, } from '@hugeicons/core-free-icons' -import type { CronJobInfo } from '../hooks/use-cron-jobs' +import type { CronJobInfo } from '../stores/cron-jobs' interface CronJobListProps { jobs: CronJobInfo[] diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index ade9db6c..041b5ead 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react' import { Loading } from '@multica/ui/components/ui/loading' import { ChatView } from '@multica/ui/components/chat-view' import { useLocalChat } from '../hooks/use-local-chat' -import { useProvider } from '../hooks/use-provider' +import { useProviderStore } from '../stores/provider' import { ApiKeyDialog } from './api-key-dialog' import { OAuthDialog } from './oauth-dialog' @@ -24,7 +24,7 @@ export function LocalChat() { clearError, } = useLocalChat() - const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProvider() + const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore() // Provider config dialog state const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx index e2d44d47..17dd2dc1 100644 --- a/apps/desktop/src/renderer/src/components/qr-code.tsx +++ b/apps/desktop/src/renderer/src/components/qr-code.tsx @@ -185,13 +185,9 @@ export function ConnectionQRCode({ {/* Info section */} -
-

- Scan with your phone to connect -

- +
{/* Expiry timer */} -
+
{isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`} - {!isExpired && ( - - )} +
- - {/* Copy link button */} -
) diff --git a/apps/desktop/src/renderer/src/components/remote-chat.tsx b/apps/desktop/src/renderer/src/components/remote-chat.tsx deleted file mode 100644 index cb691c02..00000000 --- a/apps/desktop/src/renderer/src/components/remote-chat.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Loading } from '@multica/ui/components/ui/loading' -import { ChatView } from '@multica/ui/components/chat-view' -import { DevicePairing } from '@multica/ui/components/device-pairing' -import { useGatewayChat } from '@multica/hooks/use-gateway-chat' -import type { UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection' - -export function RemoteChat({ gateway }: { gateway: UseGatewayConnectionReturn }) { - const { pageState, connectionState, error, client, identity, pairingKey, connect, disconnect } = gateway - - return ( -
- {pageState === 'loading' && ( -
- - Loading... -
- )} - - {(pageState === 'not-connected' || pageState === 'connecting') && ( - - )} - - {pageState === 'connected' && client && identity && ( - - )} -
- ) -} - -function ConnectedChat({ - client, - hubId, - agentId, -}: { - client: NonNullable - hubId: string - agentId: string -}) { - const chat = useGatewayChat({ client, hubId, agentId }) - return -} diff --git a/apps/desktop/src/renderer/src/components/skill-list.tsx b/apps/desktop/src/renderer/src/components/skill-list.tsx index 6f18501b..c74dc2c9 100644 --- a/apps/desktop/src/renderer/src/components/skill-list.tsx +++ b/apps/desktop/src/renderer/src/components/skill-list.tsx @@ -9,7 +9,7 @@ import { CheckmarkCircle02Icon, Cancel01Icon, } from '@hugeicons/core-free-icons' -import type { SkillInfo, SkillSource } from '../hooks/use-skills' +import type { SkillInfo, SkillSource } from '../stores/skills' // Source badge colors const SOURCE_COLORS: Record = { diff --git a/apps/desktop/src/renderer/src/components/tool-list.tsx b/apps/desktop/src/renderer/src/components/tool-list.tsx index 62e11cb0..9c4f556d 100644 --- a/apps/desktop/src/renderer/src/components/tool-list.tsx +++ b/apps/desktop/src/renderer/src/components/tool-list.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { Switch } from '@multica/ui/components/ui/switch' import { Button } from '@multica/ui/components/ui/button' import { HugeiconsIcon } from '@hugeicons/react' @@ -14,7 +14,18 @@ import { Time04Icon, UserMultipleIcon, } from '@hugeicons/core-free-icons' -import type { ToolInfo, ToolGroup } from '../hooks/use-tools' +import type { ToolInfo } from '../stores/tools' + +// Group display names +const GROUP_NAMES: Record = { + fs: 'File System', + runtime: 'Runtime', + web: 'Web', + memory: 'Memory', + subagent: 'Subagent', + cron: 'Cron', + other: 'Other', +} // Group icons const GROUP_ICONS: Record = { @@ -29,7 +40,6 @@ const GROUP_ICONS: Record = { interface ToolListProps { tools: ToolInfo[] - groups: ToolGroup[] loading: boolean error: string | null onToggleTool: (toolName: string) => Promise @@ -38,12 +48,23 @@ interface ToolListProps { export function ToolList({ tools, - groups, loading, error, onToggleTool, onRefresh, }: ToolListProps) { + // Compute groups from tools + const groups = useMemo(() => { + 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, + })) + }, [tools]) + // Track which groups are expanded const [expandedGroups, setExpandedGroups] = useState>( () => new Set(groups.map((g) => g.id)) @@ -77,14 +98,6 @@ export function ToolList({ } } - // Group tools by their group - const toolsByGroup = groups.map((group) => ({ - ...group, - tools: tools.filter((t) => t.group === group.id), - enabledCount: tools.filter((t) => t.group === group.id && t.enabled).length, - totalCount: tools.filter((t) => t.group === group.id).length, - })) - if (loading && tools.length === 0) { return (
@@ -126,7 +139,7 @@ export function ToolList({ {/* Tool groups */}
- {toolsByGroup.map((group) => { + {groups.map((group) => { const isExpanded = expandedGroups.has(group.id) const GroupIcon = GROUP_ICONS[group.id] || CodeIcon diff --git a/apps/desktop/src/renderer/src/hooks/use-channels.ts b/apps/desktop/src/renderer/src/hooks/use-channels.ts deleted file mode 100644 index 724b3407..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-channels.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Hook for managing channel accounts (Telegram, Discord, etc.) in the Desktop App. - * - * Uses the global ChannelsStore for state management. - * Data is fetched once at app startup and shared across all components. - */ -import { useChannelsStore } from '../stores/channels' - -export interface UseChannelsReturn { - /** Runtime states of all channel accounts */ - states: ChannelAccountStateInfo[] - /** Raw channel config from credentials.json5 */ - config: Record> | undefined> - /** Loading state */ - loading: boolean - /** Error message if any */ - error: string | null - /** Refresh states and config */ - refresh: () => Promise - /** Save a bot token — persists to file and starts the bot immediately */ - saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }> - /** Remove a bot token — stops the bot and removes from file */ - removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> - /** Stop a channel account without removing config */ - stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> - /** Start a channel account from saved config */ - startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> -} - -export function useChannels(): UseChannelsReturn { - const { - states, - config, - loading, - error, - refresh, - saveToken, - removeToken, - stopChannel, - startChannel, - } = useChannelsStore() - - return { - states, - config, - loading, - error, - refresh, - saveToken, - removeToken, - stopChannel, - startChannel, - } -} diff --git a/apps/desktop/src/renderer/src/hooks/use-cron-jobs.ts b/apps/desktop/src/renderer/src/hooks/use-cron-jobs.ts deleted file mode 100644 index e01c81e3..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-cron-jobs.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' - -export interface CronJobInfo { - id: string - name: string - description?: string - enabled: boolean - schedule: string - sessionTarget: string - nextRunAt: string | null - lastStatus: 'ok' | 'error' | 'skipped' | null - lastRunAt: string | null - lastDurationMs: number | null - lastError: string | null -} - -export interface UseCronJobsReturn { - jobs: CronJobInfo[] - loading: boolean - error: string | null - toggleJob: (jobId: string) => Promise - removeJob: (jobId: string) => Promise - refresh: () => Promise -} - -export function useCronJobs(): UseCronJobsReturn { - const [jobs, setJobs] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchJobs = useCallback(async () => { - try { - setLoading(true) - setError(null) - - const result = window.electronAPI - ? await window.electronAPI.cron.list() - : await window.ipcRenderer.invoke('cron:list') - - if (Array.isArray(result)) { - setJobs(result) - } else { - setError('Invalid response from cron:list') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs') - setJobs([]) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchJobs() - }, [fetchJobs]) - - const toggleJob = useCallback(async (jobId: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.cron.toggle(jobId) - : await window.ipcRenderer.invoke('cron:toggle', jobId) - - const typed = result as { error?: string; id?: string; enabled?: boolean } - if (typed.error) { - setError(typed.error) - return - } - - setJobs((prev) => - prev.map((job) => - job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job - ) - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to toggle job') - } - }, []) - - const removeJob = useCallback(async (jobId: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.cron.remove(jobId) - : await window.ipcRenderer.invoke('cron:remove', jobId) - - const typed = result as { error?: string; ok?: boolean } - if (typed.error) { - setError(typed.error) - return - } - - setJobs((prev) => prev.filter((job) => job.id !== jobId)) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to remove job') - } - }, []) - - return { - jobs, - loading, - error, - toggleJob, - removeJob, - refresh: fetchJobs, - } -} - -export default useCronJobs diff --git a/apps/desktop/src/renderer/src/hooks/use-heartbeat.ts b/apps/desktop/src/renderer/src/hooks/use-heartbeat.ts deleted file mode 100644 index 0bb4e3c6..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-heartbeat.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; - -export type HeartbeatEvent = { - ts: number; - status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed"; - preview?: string; - durationMs?: number; - reason?: string; -}; - -export function useHeartbeat() { - const [enabled, setEnabled] = useState(true); - const [lastEvent, setLastEvent] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const refresh = useCallback(async () => { - try { - setLoading(true); - setError(null); - const event = (await window.electronAPI.heartbeat.last()) as HeartbeatEvent | null; - setLastEvent(event); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - const timer = setInterval(() => { - void refresh(); - }, 15000); - return () => clearInterval(timer); - }, [refresh]); - - const toggleEnabled = useCallback(async () => { - const next = !enabled; - const result = await window.electronAPI.heartbeat.setEnabled(next); - if (result.ok) { - setEnabled(next); - } else { - setError(result.error ?? "Failed to update heartbeat setting"); - } - }, [enabled]); - - const wakeNow = useCallback(async () => { - setLoading(true); - try { - const result = await window.electronAPI.heartbeat.wake("manual"); - if (!result.ok) { - setError(result.error ?? "Failed to run heartbeat"); - } - await refresh(); - } finally { - setLoading(false); - } - }, [refresh]); - - return { - enabled, - lastEvent, - loading, - error, - refresh, - toggleEnabled, - wakeNow, - }; -} diff --git a/apps/desktop/src/renderer/src/hooks/use-hub.ts b/apps/desktop/src/renderer/src/hooks/use-hub.ts deleted file mode 100644 index e54e2fd5..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-hub.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' - -// ============================================================================ -// Types matching the IPC response from main process -// ============================================================================ - -export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'registered' - -export interface HubInfo { - hubId: string - url: string - connectionState: ConnectionState - agentCount: number -} - -export interface AgentInfo { - id: string - closed: boolean -} - -export interface UseHubReturn { - /** Hub information */ - hubInfo: HubInfo | null - /** List of agents */ - agents: AgentInfo[] - /** Loading state */ - loading: boolean - /** Error state */ - error: string | null - - /** Initialize the Hub (called automatically on mount) */ - initHub: () => Promise - /** Refresh Hub info and agents list */ - refresh: () => Promise - /** Reconnect to a different Gateway URL */ - reconnect: (url: string) => Promise - /** Create a new agent */ - createAgent: (id?: string) => Promise - /** Close an agent */ - closeAgent: (id: string) => Promise - /** Send a message to an agent */ - sendMessage: (agentId: string, content: string) => Promise -} - -/** - * Hook for managing Hub connection and agents via IPC. - * - * This hook communicates with the Electron main process to: - * - Initialize and manage the Hub singleton - * - Create, list, and close agents - * - Send messages to agents - */ -export function useHub(): UseHubReturn { - const [hubInfo, setHubInfo] = useState(null) - const [agents, setAgents] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - // Initialize Hub and fetch info - const initHub = useCallback(async () => { - try { - setLoading(true) - setError(null) - - // Initialize Hub (use new electronAPI if available) - if (window.electronAPI) { - await window.electronAPI.hub.init() - const info = await window.electronAPI.hub.info() - setHubInfo(info as HubInfo) - const agentList = await window.electronAPI.hub.listAgents() - setAgents(agentList as AgentInfo[]) - } else { - await window.ipcRenderer.invoke('hub:init') - const info = await window.ipcRenderer.invoke('hub:info') - setHubInfo(info) - const agentList = await window.ipcRenderer.invoke('hub:listAgents') - setAgents(agentList) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to initialize Hub') - } finally { - setLoading(false) - } - }, []) - - // Initial load - useEffect(() => { - initHub() - }, [initHub]) - - // Subscribe to connection state changes pushed from main process - useEffect(() => { - const handler = (state: string) => { - setHubInfo((prev) => prev ? { ...prev, connectionState: state as HubInfo['connectionState'] } : prev) - } - window.electronAPI?.hub.onConnectionStateChanged(handler) - return () => { - window.electronAPI?.hub.offConnectionStateChanged() - } - }, []) - - // Refresh Hub info and agents - const refresh = useCallback(async () => { - try { - setError(null) - - if (window.electronAPI) { - const info = await window.electronAPI.hub.info() - setHubInfo(info as HubInfo) - const agentList = await window.electronAPI.hub.listAgents() - setAgents(agentList as AgentInfo[]) - } else { - const info = await window.ipcRenderer.invoke('hub:info') - setHubInfo(info) - const agentList = await window.ipcRenderer.invoke('hub:listAgents') - setAgents(agentList) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to refresh Hub info') - } - }, []) - - // Reconnect to different Gateway - const reconnect = useCallback(async (url: string) => { - try { - setError(null) - if (window.electronAPI) { - await window.electronAPI.hub.reconnect(url) - } else { - await window.ipcRenderer.invoke('hub:reconnect', url) - } - await refresh() - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to reconnect') - } - }, [refresh]) - - // Create a new agent - const createAgent = useCallback(async (id?: string): Promise => { - try { - setError(null) - const result = window.electronAPI - ? await window.electronAPI.hub.createAgent(id) - : await window.ipcRenderer.invoke('hub:createAgent', id) - - const typedResult = result as { error?: string; id?: string; closed?: boolean } - if (typedResult.error) { - setError(typedResult.error) - return null - } - - // Refresh agents list - await refresh() - - return result as AgentInfo - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create agent') - return null - } - }, [refresh]) - - // Close an agent - const closeAgent = useCallback(async (id: string): Promise => { - try { - setError(null) - const result = window.electronAPI - ? await window.electronAPI.hub.closeAgent(id) - : await window.ipcRenderer.invoke('hub:closeAgent', id) - - const typedResult = result as { ok?: boolean } - if (!typedResult.ok) { - setError(`Failed to close agent: ${id}`) - return false - } - - // Refresh agents list - await refresh() - - return true - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to close agent') - return false - } - }, [refresh]) - - // Send message to agent - const sendMessage = useCallback(async (agentId: string, content: string): Promise => { - try { - setError(null) - const result = window.electronAPI - ? await window.electronAPI.hub.sendMessage(agentId, content) - : await window.ipcRenderer.invoke('hub:sendMessage', agentId, content) - - const typedResult = result as { error?: string } - if (typedResult.error) { - setError(typedResult.error) - return false - } - - return true - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to send message') - return false - } - }, []) - - return { - hubInfo, - agents, - loading, - error, - initHub, - refresh, - reconnect, - createAgent, - closeAgent, - sendMessage, - } -} - -export default useHub diff --git a/apps/desktop/src/renderer/src/hooks/use-provider.ts b/apps/desktop/src/renderer/src/hooks/use-provider.ts deleted file mode 100644 index f9c98b15..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-provider.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Hook for managing LLM providers in the Desktop App. - * - * Uses the global ProviderStore for state management. - * Data is fetched once at app startup and shared across all components. - */ -import { useCallback } from 'react' -import { useProviderStore } from '../stores/provider' - -interface UseProviderReturn { - /** All providers with their status */ - providers: ProviderStatus[] - /** Only available (configured) providers */ - availableProviders: ProviderStatus[] - /** Current provider and model info */ - current: CurrentProviderInfo | null - /** Loading state */ - loading: boolean - /** Error message if any */ - error: string | null - /** Refresh provider list and current status */ - refresh: () => Promise - /** Switch to a different provider (and optionally model) */ - setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }> - /** Get metadata for a specific provider */ - getProviderMeta: (providerId: string) => ProviderStatus | undefined -} - -export function useProvider(): UseProviderReturn { - const { - providers, - current, - loading, - error, - refresh, - setProvider, - } = useProviderStore() - - const availableProviders = providers.filter((p) => p.available) - - const getProviderMeta = useCallback( - (providerId: string) => { - return providers.find((p) => p.id === providerId) - }, - [providers] - ) - - return { - providers, - availableProviders, - current, - loading, - error, - refresh, - setProvider, - getProviderMeta, - } -} diff --git a/apps/desktop/src/renderer/src/hooks/use-skills.ts b/apps/desktop/src/renderer/src/hooks/use-skills.ts deleted file mode 100644 index 7b1fa6f1..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-skills.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' - -// ============================================================================ -// Types matching the IPC response from main process -// ============================================================================ - -export type SkillSource = 'bundled' | 'global' | 'profile' - -export interface SkillInfo { - id: string - name: string - description: string - version: string - enabled: boolean - source: SkillSource - triggers: string[] -} - -export interface SkillGroup { - source: SkillSource - name: string - skills: SkillInfo[] -} - -// Source display names -const SOURCE_NAMES: Record = { - bundled: 'Built-in Skills', - global: 'Global Skills', - profile: 'Profile Skills', -} - -export interface UseSkillsReturn { - /** List of all skills */ - skills: SkillInfo[] - /** Skills grouped by source */ - groups: SkillGroup[] - /** Loading state */ - loading: boolean - /** Error state */ - error: string | null - - /** Toggle a skill on/off */ - toggleSkill: (skillId: string) => Promise - /** Enable a skill */ - enableSkill: (skillId: string) => Promise - /** Disable a skill */ - disableSkill: (skillId: string) => Promise - - /** Refresh skills list */ - refresh: () => Promise - - /** Get skill by ID */ - getSkill: (id: string) => SkillInfo | undefined - - /** Filter skills by search query */ - filterSkills: (query: string) => SkillInfo[] - - /** Check if a skill is enabled */ - isSkillEnabled: (skillId: string) => boolean - - /** Stats */ - stats: { - total: number - enabled: number - disabled: number - bundled: number - global: number - profile: number - } -} - -/** - * Hook for managing Agent skills configuration via IPC. - * - * This hook communicates with the Electron main process to: - * - Fetch the list of all skills (bundled, global, profile) - * - Toggle skills on/off - * - Match the CLI `multica skills list` output - */ -export function useSkills(): UseSkillsReturn { - const [skills, setSkills] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - // Fetch skills from main process - const fetchSkills = useCallback(async () => { - try { - setLoading(true) - setError(null) - - // Use new electronAPI if available, fallback to ipcRenderer - const result = window.electronAPI - ? await window.electronAPI.skills.list() - : await window.ipcRenderer.invoke('skills:list') - - if (Array.isArray(result)) { - setSkills(result) - } else { - setError('Invalid response from skills:list') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch skills') - setSkills([]) - } finally { - setLoading(false) - } - }, []) - - // Initial fetch - useEffect(() => { - fetchSkills() - }, [fetchSkills]) - - // Group skills by source - const groups = useMemo(() => { - const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile'] - const groupMap = new Map() - - for (const skill of skills) { - const sourceSkills = groupMap.get(skill.source) || [] - sourceSkills.push(skill) - groupMap.set(skill.source, sourceSkills) - } - - return sourceOrder - .filter((source) => groupMap.has(source)) - .map((source) => ({ - source, - name: SOURCE_NAMES[source] || source, - skills: groupMap.get(source) || [], - })) - }, [skills]) - - // Stats - const stats = useMemo(() => ({ - total: skills.length, - enabled: skills.filter((s) => s.enabled).length, - disabled: skills.filter((s) => !s.enabled).length, - bundled: skills.filter((s) => s.source === 'bundled').length, - global: skills.filter((s) => s.source === 'global').length, - profile: skills.filter((s) => s.source === 'profile').length, - }), [skills]) - - // Toggle skill via IPC - const toggleSkill = useCallback(async (skillId: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.skills.toggle(skillId) - : await window.ipcRenderer.invoke('skills:toggle', skillId) - - const typedResult = result as { error?: string; enabled?: boolean } - if (typedResult.error) { - setError(typedResult.error) - return - } - - // Update local state - setSkills((prev) => - prev.map((skill) => - skill.id === skillId ? { ...skill, enabled: typedResult.enabled ?? !skill.enabled } : skill - ) - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to toggle skill') - } - }, []) - - // Enable skill via IPC - const enableSkill = useCallback(async (skillId: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.skills.setStatus(skillId, true) - : await window.ipcRenderer.invoke('skills:setStatus', skillId, true) - - const typedResult = result as { error?: string } - if (typedResult.error) { - setError(typedResult.error) - return - } - - setSkills((prev) => - prev.map((skill) => - skill.id === skillId ? { ...skill, enabled: true } : skill - ) - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to enable skill') - } - }, []) - - // Disable skill via IPC - const disableSkill = useCallback(async (skillId: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.skills.setStatus(skillId, false) - : await window.ipcRenderer.invoke('skills:setStatus', skillId, false) - - const typedResult = result as { error?: string } - if (typedResult.error) { - setError(typedResult.error) - return - } - - setSkills((prev) => - prev.map((skill) => - skill.id === skillId ? { ...skill, enabled: false } : skill - ) - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to disable skill') - } - }, []) - - // Get skill by ID - const getSkill = useCallback( - (id: string): SkillInfo | undefined => { - return skills.find((s) => s.id === id) - }, - [skills] - ) - - // Filter skills by search query - const filterSkills = useCallback( - (query: string): SkillInfo[] => { - if (!query.trim()) return skills - - const lowerQuery = query.toLowerCase() - return skills.filter( - (skill) => - skill.name.toLowerCase().includes(lowerQuery) || - skill.id.toLowerCase().includes(lowerQuery) || - skill.description.toLowerCase().includes(lowerQuery) || - skill.triggers.some((t) => t.toLowerCase().includes(lowerQuery)) - ) - }, - [skills] - ) - - // Check if skill is enabled - const isSkillEnabled = useCallback( - (skillId: string): boolean => { - const skill = skills.find((s) => s.id === skillId) - return skill?.enabled ?? false - }, - [skills] - ) - - return { - skills, - groups, - loading, - error, - toggleSkill, - enableSkill, - disableSkill, - refresh: fetchSkills, - getSkill, - filterSkills, - isSkillEnabled, - stats, - } -} - -export default useSkills diff --git a/apps/desktop/src/renderer/src/hooks/use-tools.ts b/apps/desktop/src/renderer/src/hooks/use-tools.ts deleted file mode 100644 index 4f8130ca..00000000 --- a/apps/desktop/src/renderer/src/hooks/use-tools.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' - -// ============================================================================ -// Types matching the IPC response from main process -// ============================================================================ - -export interface ToolInfo { - name: string - description?: string - group: string - enabled: boolean -} - -export interface ToolGroup { - id: string - name: string - tools: string[] -} - -// Tool descriptions (for UI display) -const TOOL_DESCRIPTIONS: Record = { - read: 'Read file contents', - write: 'Write content to file', - edit: 'Edit file with search/replace', - glob: 'Find files by pattern', - exec: 'Execute shell commands', - process: 'Manage background processes', - web_fetch: 'Fetch content from URLs', - web_search: 'Search the web via Devv Search', - memory_get: 'Get stored memory value', - memory_set: 'Store a memory value', - memory_delete: 'Delete a memory value', - memory_list: 'List all memory keys', - memory_search: 'Search memory files for keywords', - cron: 'Create and manage scheduled tasks', -} - -// Group display names -const GROUP_NAMES: Record = { - fs: 'File System', - runtime: 'Runtime', - web: 'Web', - memory: 'Memory', - subagent: 'Subagent', - cron: 'Cron', - other: 'Other', -} - -export interface UseToolsReturn { - /** List of all tools with their status */ - tools: ToolInfo[] - /** List of tool groups */ - groups: ToolGroup[] - /** Loading state */ - loading: boolean - /** Error state */ - error: string | null - - /** Toggle a specific tool on/off */ - toggleTool: (toolName: string) => Promise - /** Enable a tool */ - enableTool: (toolName: string) => Promise - /** Disable a tool */ - disableTool: (toolName: string) => Promise - - /** Refresh tools list from main process */ - refresh: () => Promise - - /** Check if a tool is enabled */ - isToolEnabled: (toolName: string) => boolean -} - -/** - * Hook for managing Agent tools configuration via IPC. - * - * This hook communicates with the Electron main process to: - * - Fetch the list of available tools and their status - * - Toggle tools on/off (persisted to credentials.json5) - * - Trigger agent.reloadTools() to apply changes immediately - */ -export function useTools(): UseToolsReturn { - const [tools, setTools] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - // Fetch tools from main process - const fetchTools = useCallback(async () => { - try { - setLoading(true) - setError(null) - - // Use new electronAPI if available, fallback to ipcRenderer - const result = window.electronAPI - ? await window.electronAPI.tools.list() - : await window.ipcRenderer.invoke('tools:list') - - if (Array.isArray(result)) { - // Add descriptions to tools - const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({ - ...tool, - description: TOOL_DESCRIPTIONS[tool.name], - })) - setTools(toolsWithDesc) - } else { - setError('Invalid response from tools:list') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch tools') - // Fallback to empty list - setTools([]) - } finally { - setLoading(false) - } - }, []) - - // Initial fetch - useEffect(() => { - fetchTools() - }, [fetchTools]) - - // Build groups list from tools - const groups = useMemo(() => { - const groupMap = new Map() - - for (const tool of tools) { - const groupTools = groupMap.get(tool.group) || [] - groupTools.push(tool.name) - groupMap.set(tool.group, groupTools) - } - - return Array.from(groupMap.entries()).map(([id, toolNames]) => ({ - id, - name: GROUP_NAMES[id] || id, - tools: toolNames, - })) - }, [tools]) - - // Toggle tool via IPC - const toggleTool = useCallback(async (toolName: string) => { - console.log('[useTools] toggleTool called:', toolName) - try { - const result = window.electronAPI - ? await window.electronAPI.tools.toggle(toolName) - : await window.ipcRenderer.invoke('tools:toggle', toolName) - - console.log('[useTools] toggleTool result:', result) - - const typedResult = result as { error?: string; enabled?: boolean } - if (typedResult.error) { - console.error('[useTools] toggleTool error:', typedResult.error) - setError(typedResult.error) - return - } - - // Update local state - console.log('[useTools] Updating tool state:', toolName, 'enabled:', typedResult.enabled) - setTools((prev) => - prev.map((tool) => - tool.name === toolName ? { ...tool, enabled: typedResult.enabled ?? !tool.enabled } : tool - ) - ) - } catch (err) { - console.error('[useTools] toggleTool exception:', err) - setError(err instanceof Error ? err.message : 'Failed to toggle tool') - } - }, []) - - // Enable tool via IPC - const enableTool = useCallback(async (toolName: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.tools.setStatus(toolName, true) - : await window.ipcRenderer.invoke('tools:setStatus', toolName, true) - - const typedResult = result as { error?: string } - if (typedResult.error) { - setError(typedResult.error) - return - } - - setTools((prev) => - prev.map((tool) => - tool.name === toolName ? { ...tool, enabled: true } : tool - ) - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to enable tool') - } - }, []) - - // Disable tool via IPC - const disableTool = useCallback(async (toolName: string) => { - try { - const result = window.electronAPI - ? await window.electronAPI.tools.setStatus(toolName, false) - : await window.ipcRenderer.invoke('tools:setStatus', toolName, false) - - const typedResult = result as { error?: string } - if (typedResult.error) { - setError(typedResult.error) - return - } - - setTools((prev) => - prev.map((tool) => - tool.name === toolName ? { ...tool, enabled: false } : tool - ) - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to disable tool') - } - }, []) - - // Check if tool is enabled - const isToolEnabled = useCallback( - (toolName: string): boolean => { - const tool = tools.find((t) => t.name === toolName) - return tool?.enabled ?? false - }, - [tools] - ) - - return { - tools, - groups, - loading, - error, - toggleTool, - enableTool, - disableTool, - refresh: fetchTools, - isToolEnabled, - } -} - -export default useTools diff --git a/apps/desktop/src/renderer/src/pages/admin.tsx b/apps/desktop/src/renderer/src/pages/admin.tsx index b3365b3d..bf962d69 100644 --- a/apps/desktop/src/renderer/src/pages/admin.tsx +++ b/apps/desktop/src/renderer/src/pages/admin.tsx @@ -17,13 +17,13 @@ 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 { useHub } from '../hooks/use-hub' -import { useProvider } from '../hooks/use-provider' +import { useHubStore } from '../stores/hub' +import { useProviderStore } from '../stores/provider' export default function HomePage() { const navigate = useNavigate() - const { hubInfo, agents, loading, error } = useHub() - const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider() + const { hubInfo, agents, loading, error } = useHubStore() + const { providers, current, setProvider, refresh, loading: providerLoading } = useProviderStore() const [settingsOpen, setSettingsOpen] = useState(false) const [agentName, setAgentName] = useState() const [providerDropdownOpen, setProviderDropdownOpen] = useState(false) diff --git a/apps/desktop/src/renderer/src/pages/channels.tsx b/apps/desktop/src/renderer/src/pages/channels.tsx index 81bc8926..829a3056 100644 --- a/apps/desktop/src/renderer/src/pages/channels.tsx +++ b/apps/desktop/src/renderer/src/pages/channels.tsx @@ -9,7 +9,7 @@ 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 { useChannels, type UseChannelsReturn } from '../hooks/use-channels' +import { useChannelsStore } from '../stores/channels' /** Status badge color mapping */ function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { @@ -21,8 +21,8 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive' } } -function TelegramCard({ channels }: { channels: UseChannelsReturn }) { - const { states, config, saveToken, removeToken, startChannel, stopChannel } = channels +function TelegramCard() { + const { states, config, saveToken, removeToken, startChannel, stopChannel } = useChannelsStore() const [token, setToken] = useState('') const [saving, setSaving] = useState(false) const [localError, setLocalError] = useState(null) @@ -153,25 +153,28 @@ function TelegramCard({ channels }: { channels: UseChannelsReturn }) { } export default function ChannelsPage() { - const channels = useChannels() - const { loading, error } = channels + const { loading, error } = useChannelsStore() return ( -
-
-

Channels

+
+ {/* Page Header */} +
+

Channels

- Connect messaging platforms to your Agent. + Channels let you talk to your agent from other platforms like Telegram or Slack. Connect one to chat with your agent anywhere.

- {loading ? ( -

Loading...

- ) : error ? ( -

{error}

- ) : ( - - )} + {/* Configuration Area */} +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + )} +
) } diff --git a/apps/desktop/src/renderer/src/pages/chat.tsx b/apps/desktop/src/renderer/src/pages/chat.tsx index 1581bdf6..8900a10a 100644 --- a/apps/desktop/src/renderer/src/pages/chat.tsx +++ b/apps/desktop/src/renderer/src/pages/chat.tsx @@ -1,128 +1,9 @@ -import { Button } from '@multica/ui/components/ui/button' -import { RemoteChat } from '../components/remote-chat' import { LocalChat } from '../components/local-chat' -import { useChatModeStore } from '../stores/chat-mode' -import { useGatewayConnection, type UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection' - -function ModeNav({ gateway }: { gateway: UseGatewayConnectionReturn }) { - const { mode, setMode } = useChatModeStore() - - if (mode === 'select') return null - - return ( -
- setMode('local')}> - Local - - setMode('remote')}> - Remote - - - {mode === 'remote' && gateway.pageState === 'connected' && ( - <> -
- - - )} -
- ) -} - -function NavButton({ - active, - onClick, - children, -}: { - active: boolean - onClick: () => void - children: React.ReactNode -}) { - return ( - - ) -} - -function ModeSelect() { - const setMode = useChatModeStore((s) => s.setMode) - - return ( -
-
-

Start a Conversation

-

- Choose how you want to connect -

-
- -
- - - -
-
- ) -} export default function ChatPage() { - const mode = useChatModeStore((s) => s.mode) - const gateway = useGatewayConnection() - return (
- - - {mode === 'select' && } - - {mode === 'local' && } - - - - -
- ) -} - -function ChatPanel({ - visible, - children, -}: { - visible: boolean - children: React.ReactNode -}) { - return ( -
- {children} +
) } diff --git a/apps/desktop/src/renderer/src/pages/crons.tsx b/apps/desktop/src/renderer/src/pages/crons.tsx index 0947d5c3..d278a09d 100644 --- a/apps/desktop/src/renderer/src/pages/crons.tsx +++ b/apps/desktop/src/renderer/src/pages/crons.tsx @@ -1,43 +1,30 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@multica/ui/components/ui/card' -import { useCronJobs } from '../hooks/use-cron-jobs' +import { useCronJobsStore } from '../stores/cron-jobs' import { CronJobList } from '../components/cron-job-list' export default function CronsPage() { - const { - jobs, - loading, - error, - toggleJob, - removeJob, - refresh, - } = useCronJobs() + const { jobs, loading, error, toggleJob, removeJob, refresh } = useCronJobsStore() return ( -
- - - Cron Jobs - - View and manage scheduled tasks. Create new jobs by asking the Agent in Chat. - - - - - - +
+ {/* Page Header */} +
+

Scheduled Tasks

+

+ Scheduled tasks run automatically at set times. Ask your agent to create one, like "remind me every morning" or "check my inbox daily." +

+
+ + {/* Configuration Area */} +
+ +
) } diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 9d0f0c31..01921318 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -1,18 +1,22 @@ -import { Outlet, NavLink, useLocation } from 'react-router-dom' +import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom' import { Toaster } from '@multica/ui/components/ui/sonner' +import { Button } from '@multica/ui/components/ui/button' import { HugeiconsIcon } from '@hugeicons/react' import { Home01Icon, Comment01Icon, - CodeIcon, - PlugIcon, - Share08Icon, - Time04Icon, + PuzzleIcon, + Wrench01Icon, + Message01Icon, + RepeatIcon, + ArrowLeft02Icon, + ArrowRight02Icon, } from '@hugeicons/core-free-icons' import { Sidebar, SidebarContent, SidebarGroup, + SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, @@ -26,19 +30,67 @@ import { cn } from '@multica/ui/lib/utils' import { ModeToggle } from '../components/mode-toggle' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' -const navItems = [ +const mainNavItems = [ { path: '/', label: 'Home', icon: Home01Icon }, { path: '/chat', label: 'Chat', icon: Comment01Icon }, - { path: '/tools', label: 'Tools', icon: CodeIcon }, - { path: '/skills', label: 'Skills', icon: PlugIcon }, - { path: '/channels', label: 'Channels', icon: Share08Icon }, - { path: '/crons', label: 'Crons', icon: Time04Icon }, ] +const configNavItems = [ + { path: '/skills', label: 'Skills', icon: PuzzleIcon }, + { path: '/tools', label: 'Tools', icon: Wrench01Icon }, + { path: '/channels', label: 'Channels', icon: Message01Icon }, + { path: '/crons', label: 'Crons', icon: RepeatIcon }, +] + +// All nav items for header lookup +const allNavItems = [...mainNavItems, ...configNavItems] + +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 + const canGoBack = historyIdx > 0 + const canGoForward = historyIdx < window.history.length - 1 + + return ( +
+ + +
+ ) +} + function MainHeader() { const { state, isMobile } = useSidebar() + 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) + ) + return (
{/* Drag placeholder for traffic lights when sidebar is collapsed */} @@ -52,8 +104,15 @@ function MainHeader() { - {/* Spacer */} -
+ {/* Center: Current page */} +
+ {currentPage && ( +
+ + {currentPage.label} +
+ )} +
{/* Right: Theme toggle */} @@ -68,16 +127,19 @@ export default function Layout() {
- {/* Traffic light area */} + {/* Traffic light area with navigation */} + > + + + {/* Main navigation */} - - {navItems.map((item) => { + + {mainNavItems.map((item) => { const isActive = item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path) @@ -85,7 +147,39 @@ export default function Layout() { - + + {item.label} + + + + ) + })} + + + + {/* Configuration */} + + Configuration + + {configNavItems.map((item) => { + const isActive = location.pathname.startsWith(item.path) + return ( + + + + {item.label} diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx index 75952b05..bdabb52a 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/connect-step.tsx @@ -17,7 +17,7 @@ import { Tick02Icon, InformationCircleIcon, } from '@hugeicons/core-free-icons' -import { useChannels } from '../../../hooks/use-channels' +import { useChannelsStore } from '../../../stores/channels' import { StepDots } from './step-dots' function statusVariant( @@ -41,7 +41,7 @@ interface ConnectStepProps { } export default function ConnectStep({ onNext, onBack }: ConnectStepProps) { - const { states, config, saveToken } = useChannels() + const { states, config, saveToken } = useChannelsStore() const [token, setToken] = useState('') const [saving, setSaving] = useState(false) diff --git a/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx b/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx index 3a5b7906..b5423a7f 100644 --- a/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx +++ b/apps/desktop/src/renderer/src/pages/onboarding/components/setup-step.tsx @@ -10,7 +10,7 @@ import { Link } from '@multica/ui/components/ui/link' import { HugeiconsIcon } from '@hugeicons/react' import { ArrowLeft02Icon, HelpCircleIcon } from '@hugeicons/core-free-icons' import { cn } from '@multica/ui/lib/utils' -import { useProvider } from '../../../hooks/use-provider' +import { useProviderStore } from '../../../stores/provider' import { ApiKeyDialog } from '../../../components/api-key-dialog' import { OAuthDialog } from '../../../components/oauth-dialog' import { StepDots } from './step-dots' @@ -25,7 +25,7 @@ interface SetupStepProps { export default function SetupStep({ onNext, onBack }: SetupStepProps) { const { providers, current, loading, error, refresh, setProvider } = - useProvider() + useProviderStore() const { setProviderConfigured } = useOnboardingStore() const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) diff --git a/apps/desktop/src/renderer/src/pages/skills.tsx b/apps/desktop/src/renderer/src/pages/skills.tsx index fde22b9e..607b9977 100644 --- a/apps/desktop/src/renderer/src/pages/skills.tsx +++ b/apps/desktop/src/renderer/src/pages/skills.tsx @@ -1,42 +1,29 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@multica/ui/components/ui/card' -import { useSkills } from '../hooks/use-skills' +import { useSkillsStore } from '../stores/skills' import { SkillList } from '../components/skill-list' export default function SkillsPage() { - const { - skills, - loading, - error, - toggleSkill, - refresh, - } = useSkills() + const { skills, loading, error, toggleSkill, refresh } = useSkillsStore() return ( -
- - - Skills - - Manage agent skills. Skills provide specialized capabilities like Git integration, - code review, and file manipulation. Toggle skills on/off to control agent behavior. - - - - - - +
+ {/* Page Header */} +
+

Skills

+

+ Skills are modular capabilities that expand what your agent can do. You can also ask your agent to create new skills for you. +

+
+ + {/* Configuration Area */} +
+ +
) } diff --git a/apps/desktop/src/renderer/src/pages/tools.tsx b/apps/desktop/src/renderer/src/pages/tools.tsx index 6825db55..1d977e40 100644 --- a/apps/desktop/src/renderer/src/pages/tools.tsx +++ b/apps/desktop/src/renderer/src/pages/tools.tsx @@ -1,44 +1,29 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@multica/ui/components/ui/card' -import { useTools } from '../hooks/use-tools' +import { useToolsStore } from '../stores/tools' import { ToolList } from '../components/tool-list' export default function ToolsPage() { - const { - tools, - groups, - loading, - error, - toggleTool, - refresh, - } = useTools() + const { tools, loading, error, toggleTool, refresh } = useToolsStore() return ( -
- - - Tools - - Configure which tools are available to the Agent. Toggle individual tools on/off. - Changes apply immediately to the running Agent. - - - - - - +
+ {/* Page Header */} +
+

Tools

+

+ 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. +

+
+ + {/* Configuration Area */} +
+ +
) } diff --git a/apps/desktop/src/renderer/src/stores/channels.ts b/apps/desktop/src/renderer/src/stores/channels.ts index 5242efbb..ea6e1be8 100644 --- a/apps/desktop/src/renderer/src/stores/channels.ts +++ b/apps/desktop/src/renderer/src/stores/channels.ts @@ -1,4 +1,8 @@ import { create } from 'zustand' +import { toast } from '@multica/ui/components/ui/sonner' + +// Minimum loading time for user perception (ms) +const MIN_LOADING_TIME = 800 interface ChannelsStore { // State @@ -53,12 +57,20 @@ export const useChannelsStore = create()((set, get) => ({ refresh: async () => { set({ loading: true, error: null }) + const startTime = Date.now() + try { const [stateList, channelConfig] = await Promise.all([ window.electronAPI.channels.listStates(), window.electronAPI.channels.getConfig(), ]) + // Ensure minimum loading time for user perception + const elapsed = Date.now() - startTime + if (elapsed < MIN_LOADING_TIME) { + await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed)) + } + set({ states: stateList, config: channelConfig, @@ -66,6 +78,7 @@ export const useChannelsStore = create()((set, get) => ({ } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to refresh channels', { description: message }) console.error('[ChannelsStore] Failed to refresh:', message) } finally { set({ loading: false }) @@ -80,14 +93,17 @@ export const useChannelsStore = create()((set, get) => ({ if (result.ok) { await get().refresh() + toast.success('Channel connected') return { ok: true } } else { set({ error: result.error ?? 'Failed to save token' }) + toast.error('Failed to connect channel', { description: result.error }) return { ok: false, error: result.error } } } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to connect channel', { description: message }) return { ok: false, error: message } } }, @@ -100,14 +116,17 @@ export const useChannelsStore = create()((set, get) => ({ if (result.ok) { await get().refresh() + toast.success('Channel removed') return { ok: true } } else { set({ error: result.error ?? 'Failed to remove token' }) + toast.error('Failed to remove channel', { description: result.error }) return { ok: false, error: result.error } } } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to remove channel', { description: message }) return { ok: false, error: message } } }, @@ -120,14 +139,17 @@ export const useChannelsStore = create()((set, get) => ({ if (result.ok) { await get().refresh() + toast.success('Channel stopped') return { ok: true } } else { set({ error: result.error ?? 'Failed to stop channel' }) + toast.error('Failed to stop channel', { description: result.error }) return { ok: false, error: result.error } } } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to stop channel', { description: message }) return { ok: false, error: message } } }, @@ -140,14 +162,17 @@ export const useChannelsStore = create()((set, get) => ({ if (result.ok) { await get().refresh() + toast.success('Channel started') return { ok: true } } else { set({ error: result.error ?? 'Failed to start channel' }) + toast.error('Failed to start channel', { description: result.error }) return { ok: false, error: result.error } } } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to start channel', { description: message }) return { ok: false, error: message } } }, diff --git a/apps/desktop/src/renderer/src/stores/chat-mode.ts b/apps/desktop/src/renderer/src/stores/chat-mode.ts deleted file mode 100644 index 4737f363..00000000 --- a/apps/desktop/src/renderer/src/stores/chat-mode.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { create } from "zustand" - -export type ChatMode = "select" | "local" | "remote" - -interface ChatModeStore { - mode: ChatMode - setMode: (mode: ChatMode) => void -} - -export const useChatModeStore = create((set) => ({ - mode: "select", - setMode: (mode) => set({ mode }), -})) diff --git a/apps/desktop/src/renderer/src/stores/cron-jobs.ts b/apps/desktop/src/renderer/src/stores/cron-jobs.ts new file mode 100644 index 00000000..b27fdf5c --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/cron-jobs.ts @@ -0,0 +1,162 @@ +import { create } from 'zustand' +import { toast } from '@multica/ui/components/ui/sonner' + +// Minimum loading time for user perception (ms) +const MIN_LOADING_TIME = 800 + +// Types matching the IPC response +export interface CronJobInfo { + id: string + name: string + description?: string + enabled: boolean + schedule: string + sessionTarget: string + nextRunAt: string | null + lastStatus: 'ok' | 'error' | 'skipped' | null + lastRunAt: string | null + lastDurationMs: number | null + lastError: string | null +} + +interface CronJobsStore { + // State + jobs: CronJobInfo[] + loading: boolean + error: string | null + initialized: boolean + + // Actions + fetch: () => Promise + refresh: () => Promise + toggleJob: (jobId: string) => Promise + removeJob: (jobId: string) => Promise +} + +export const useCronJobsStore = create()((set, get) => ({ + jobs: [], + loading: false, + error: null, + initialized: false, + + fetch: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + const result = await window.electronAPI.cron.list() + + if (Array.isArray(result)) { + set({ + jobs: result as CronJobInfo[], + initialized: true, + }) + } else { + set({ error: 'Invalid response from cron:list' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[CronJobsStore] Failed to load:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ loading: true, error: null }) + + const startTime = Date.now() + + try { + const result = await window.electronAPI.cron.list() + + // Ensure minimum loading time for user perception + const elapsed = Date.now() - startTime + if (elapsed < MIN_LOADING_TIME) { + await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed)) + } + + if (Array.isArray(result)) { + set({ jobs: result as CronJobInfo[] }) + } else { + set({ error: 'Invalid response from cron:list' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to refresh tasks', { description: message }) + console.error('[CronJobsStore] Failed to refresh:', message) + } finally { + set({ loading: false }) + } + }, + + toggleJob: async (jobId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.cron.toggle(jobId) + const typedResult = result as { error?: string; id?: string; enabled?: boolean } + + if (typedResult.error) { + set({ error: typedResult.error }) + toast.error('Failed to toggle task', { description: typedResult.error }) + return + } + + // Find job name for toast + const job = get().jobs.find(j => j.id === jobId) + const jobName = job?.name ?? jobId + + // Update local state + set((state) => ({ + jobs: state.jobs.map((job) => + job.id === jobId + ? { ...job, enabled: typedResult.enabled ?? !job.enabled } + : job + ), + })) + + toast.success(`${jobName} ${typedResult.enabled ? 'enabled' : 'disabled'}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to toggle task', { description: message }) + console.error('[CronJobsStore] Failed to toggle:', message) + } + }, + + removeJob: async (jobId: string) => { + set({ error: null }) + + try { + // Find job name before removing + const job = get().jobs.find(j => j.id === jobId) + const jobName = job?.name ?? jobId + + const result = await window.electronAPI.cron.remove(jobId) + const typedResult = result as { error?: string; ok?: boolean } + + if (typedResult.error) { + set({ error: typedResult.error }) + toast.error('Failed to remove task', { description: typedResult.error }) + return + } + + // Update local state + set((state) => ({ + jobs: state.jobs.filter((job) => job.id !== jobId), + })) + + toast.success(`${jobName} removed`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to remove task', { description: message }) + console.error('[CronJobsStore] Failed to remove:', message) + } + }, +})) diff --git a/apps/desktop/src/renderer/src/stores/hub.ts b/apps/desktop/src/renderer/src/stores/hub.ts new file mode 100644 index 00000000..841ab25b --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/hub.ts @@ -0,0 +1,112 @@ +import { create } from 'zustand' + +// Connection state types +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'registered' + +export interface HubInfo { + hubId: string + url: string + connectionState: ConnectionState + agentCount: number +} + +export interface AgentInfo { + id: string + closed: boolean +} + +interface HubStore { + // State + hubInfo: HubInfo | null + agents: AgentInfo[] + loading: boolean + error: string | null + initialized: boolean + + // Actions + init: () => Promise + refresh: () => Promise + reconnect: (url: string) => Promise<{ ok: boolean; error?: string }> +} + +export const useHubStore = create()((set, get) => ({ + hubInfo: null, + agents: [], + loading: false, + error: null, + initialized: false, + + init: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + await window.electronAPI.hub.init() + const info = await window.electronAPI.hub.info() + const agentList = await window.electronAPI.hub.listAgents() + + set({ + hubInfo: info as HubInfo, + agents: agentList as AgentInfo[], + initialized: true, + }) + + // Subscribe to connection state changes + window.electronAPI.hub.onConnectionStateChanged((state: string) => { + set((prev) => ({ + hubInfo: prev.hubInfo + ? { ...prev.hubInfo, connectionState: state as ConnectionState } + : prev.hubInfo, + })) + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[HubStore] Failed to initialize:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ error: null }) + + try { + const info = await window.electronAPI.hub.info() + const agentList = await window.electronAPI.hub.listAgents() + + set({ + hubInfo: info as HubInfo, + agents: agentList as AgentInfo[], + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[HubStore] Failed to refresh:', message) + } + }, + + reconnect: async (url: string) => { + set({ error: null }) + + try { + await window.electronAPI.hub.reconnect(url) + await get().refresh() + return { ok: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return { ok: false, error: message } + } + }, +})) + +// Selector helpers +export const selectPrimaryAgent = (agents: AgentInfo[]) => agents[0] ?? null + +export const selectIsConnected = (hubInfo: HubInfo | null) => { + if (!hubInfo) return false + return hubInfo.connectionState === 'connected' || hubInfo.connectionState === 'registered' +} diff --git a/apps/desktop/src/renderer/src/stores/provider.ts b/apps/desktop/src/renderer/src/stores/provider.ts index bc35572f..8935b24c 100644 --- a/apps/desktop/src/renderer/src/stores/provider.ts +++ b/apps/desktop/src/renderer/src/stores/provider.ts @@ -1,4 +1,8 @@ import { create } from 'zustand' +import { toast } from '@multica/ui/components/ui/sonner' + +// Minimum loading time for user perception (ms) +const MIN_LOADING_TIME = 800 interface ProviderStore { // State @@ -50,12 +54,20 @@ export const useProviderStore = create()((set, get) => ({ refresh: async () => { set({ loading: true, error: null }) + const startTime = Date.now() + try { const [providerList, currentInfo] = await Promise.all([ window.electronAPI.provider.list(), window.electronAPI.provider.current(), ]) + // Ensure minimum loading time for user perception + const elapsed = Date.now() - startTime + if (elapsed < MIN_LOADING_TIME) { + await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed)) + } + set({ providers: providerList, current: currentInfo, @@ -63,6 +75,7 @@ export const useProviderStore = create()((set, get) => ({ } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to refresh providers', { description: message }) console.error('[ProviderStore] Failed to refresh providers:', message) } finally { set({ loading: false }) @@ -78,15 +91,27 @@ export const useProviderStore = create()((set, get) => ({ if (result.ok) { // Refresh to update current status await get().refresh() + // Find provider name for toast + const provider = get().providers.find(p => p.id === providerId) + toast.success(`Switched to ${provider?.name ?? providerId}`) return { ok: true } } else { set({ error: result.error ?? 'Unknown error' }) + toast.error('Failed to switch provider', { description: result.error }) return { ok: false, error: result.error } } } catch (err) { const message = err instanceof Error ? err.message : String(err) set({ error: message }) + toast.error('Failed to switch provider', { description: message }) return { ok: false, error: message } } }, })) + +// Selector helpers +export const selectAvailableProviders = (providers: ProviderStatus[]) => + providers.filter(p => p.available) + +export const selectProviderById = (providers: ProviderStatus[], id: string) => + providers.find(p => p.id === id) diff --git a/apps/desktop/src/renderer/src/stores/skills.ts b/apps/desktop/src/renderer/src/stores/skills.ts new file mode 100644 index 00000000..d26658e4 --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/skills.ts @@ -0,0 +1,175 @@ +import { create } from 'zustand' +import { toast } from '@multica/ui/components/ui/sonner' + +// Minimum loading time for user perception (ms) +const MIN_LOADING_TIME = 800 + +// Types matching the IPC response +export type SkillSource = 'bundled' | 'global' | 'profile' + +export interface SkillInfo { + id: string + name: string + description: string + version: string + enabled: boolean + source: SkillSource + triggers: string[] +} + +interface SkillsStore { + // State + skills: SkillInfo[] + loading: boolean + error: string | null + initialized: boolean + + // Actions + fetch: () => Promise + refresh: () => Promise + toggleSkill: (skillId: string) => Promise + setSkillStatus: (skillId: string, enabled: boolean) => Promise +} + +export const useSkillsStore = create()((set, get) => ({ + skills: [], + loading: false, + error: null, + initialized: false, + + fetch: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + const result = await window.electronAPI.skills.list() + + if (Array.isArray(result)) { + set({ + skills: result, + initialized: true, + }) + } else { + set({ error: 'Invalid response from skills:list' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[SkillsStore] Failed to load:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ loading: true, error: null }) + + const startTime = Date.now() + + try { + const result = await window.electronAPI.skills.list() + + // Ensure minimum loading time for user perception + const elapsed = Date.now() - startTime + if (elapsed < MIN_LOADING_TIME) { + await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed)) + } + + if (Array.isArray(result)) { + set({ skills: result }) + } else { + set({ error: 'Invalid response from skills:list' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to refresh skills', { description: message }) + console.error('[SkillsStore] Failed to refresh:', message) + } finally { + set({ loading: false }) + } + }, + + toggleSkill: async (skillId: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.skills.toggle(skillId) + const typedResult = result as { error?: string; enabled?: boolean } + + if (typedResult.error) { + set({ error: typedResult.error }) + toast.error('Failed to toggle skill', { description: typedResult.error }) + return + } + + // Find skill name for toast + const skill = get().skills.find(s => s.id === skillId) + const skillName = skill?.name ?? skillId + + // Update local state + set((state) => ({ + skills: state.skills.map((skill) => + skill.id === skillId + ? { ...skill, enabled: typedResult.enabled ?? !skill.enabled } + : skill + ), + })) + + toast.success(`${skillName} ${typedResult.enabled ? 'enabled' : 'disabled'}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to toggle skill', { description: message }) + console.error('[SkillsStore] Failed to toggle:', message) + } + }, + + setSkillStatus: async (skillId: string, enabled: boolean) => { + set({ error: null }) + + try { + const result = await window.electronAPI.skills.setStatus(skillId, enabled) + const typedResult = result as { error?: string } + + if (typedResult.error) { + set({ error: typedResult.error }) + toast.error('Failed to update skill', { description: typedResult.error }) + return + } + + // Find skill name for toast + const skill = get().skills.find(s => s.id === skillId) + const skillName = skill?.name ?? skillId + + // Update local state + set((state) => ({ + skills: state.skills.map((skill) => + skill.id === skillId ? { ...skill, enabled } : skill + ), + })) + + toast.success(`${skillName} ${enabled ? 'enabled' : 'disabled'}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to update skill', { description: message }) + console.error('[SkillsStore] Failed to set status:', message) + } + }, +})) + +// Selector helpers (use with useMemo in components) +export const selectEnabledSkills = (skills: SkillInfo[]) => + skills.filter(s => s.enabled) + +export const selectSkillStats = (skills: SkillInfo[]) => ({ + total: skills.length, + enabled: skills.filter(s => s.enabled).length, + disabled: skills.filter(s => !s.enabled).length, + bundled: skills.filter(s => s.source === 'bundled').length, + global: skills.filter(s => s.source === 'global').length, + profile: skills.filter(s => s.source === 'profile').length, +}) diff --git a/apps/desktop/src/renderer/src/stores/tools.ts b/apps/desktop/src/renderer/src/stores/tools.ts new file mode 100644 index 00000000..6069f245 --- /dev/null +++ b/apps/desktop/src/renderer/src/stores/tools.ts @@ -0,0 +1,183 @@ +import { create } from 'zustand' +import { toast } from '@multica/ui/components/ui/sonner' + +// Minimum loading time for user perception (ms) +const MIN_LOADING_TIME = 800 + +// Types matching the IPC response +export interface ToolInfo { + name: string + description?: string + group: string + enabled: boolean +} + +// Tool descriptions (for UI display) +const TOOL_DESCRIPTIONS: Record = { + read: 'Read file contents', + write: 'Write content to file', + edit: 'Edit file with search/replace', + glob: 'Find files by pattern', + exec: 'Execute shell commands', + process: 'Manage background processes', + web_fetch: 'Fetch content from URLs', + web_search: 'Search the web via Devv Search', + memory_get: 'Get stored memory value', + memory_set: 'Store a memory value', + memory_delete: 'Delete a memory value', + memory_list: 'List all memory keys', + memory_search: 'Search memory files for keywords', + cron: 'Create and manage scheduled tasks', +} + +interface ToolsStore { + // State + tools: ToolInfo[] + loading: boolean + error: string | null + initialized: boolean + + // Actions + fetch: () => Promise + refresh: () => Promise + toggleTool: (toolName: string) => Promise + setToolStatus: (toolName: string, enabled: boolean) => Promise +} + +export const useToolsStore = create()((set, get) => ({ + tools: [], + loading: false, + error: null, + initialized: false, + + fetch: async () => { + // Skip if already initialized + if (get().initialized) return + + set({ loading: true, error: null }) + + try { + const result = await window.electronAPI.tools.list() + + if (Array.isArray(result)) { + // Add descriptions to tools + const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({ + ...tool, + description: TOOL_DESCRIPTIONS[tool.name], + })) + set({ + tools: toolsWithDesc, + initialized: true, + }) + } else { + set({ error: 'Invalid response from tools:list' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + console.error('[ToolsStore] Failed to load:', message) + } finally { + set({ loading: false }) + } + }, + + refresh: async () => { + set({ loading: true, error: null }) + + const startTime = Date.now() + + try { + const result = await window.electronAPI.tools.list() + + // Ensure minimum loading time for user perception + const elapsed = Date.now() - startTime + if (elapsed < MIN_LOADING_TIME) { + await new Promise(resolve => setTimeout(resolve, MIN_LOADING_TIME - elapsed)) + } + + if (Array.isArray(result)) { + const toolsWithDesc = result.map((tool: { name: string; enabled: boolean; group: string }) => ({ + ...tool, + description: TOOL_DESCRIPTIONS[tool.name], + })) + set({ tools: toolsWithDesc }) + } else { + set({ error: 'Invalid response from tools:list' }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to refresh tools', { description: message }) + console.error('[ToolsStore] Failed to refresh:', message) + } finally { + set({ loading: false }) + } + }, + + toggleTool: async (toolName: string) => { + set({ error: null }) + + try { + const result = await window.electronAPI.tools.toggle(toolName) + const typedResult = result as { error?: string; enabled?: boolean } + + if (typedResult.error) { + set({ error: typedResult.error }) + toast.error('Failed to toggle tool', { description: typedResult.error }) + return + } + + // Update local state + set((state) => ({ + tools: state.tools.map((tool) => + tool.name === toolName + ? { ...tool, enabled: typedResult.enabled ?? !tool.enabled } + : tool + ), + })) + + toast.success(`${toolName} ${typedResult.enabled ? 'enabled' : 'disabled'}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to toggle tool', { description: message }) + console.error('[ToolsStore] Failed to toggle:', message) + } + }, + + setToolStatus: async (toolName: string, enabled: boolean) => { + set({ error: null }) + + try { + const result = await window.electronAPI.tools.setStatus(toolName, enabled) + const typedResult = result as { error?: string } + + if (typedResult.error) { + set({ error: typedResult.error }) + toast.error('Failed to update tool', { description: typedResult.error }) + return + } + + // Update local state + set((state) => ({ + tools: state.tools.map((tool) => + tool.name === toolName ? { ...tool, enabled } : tool + ), + })) + + toast.success(`${toolName} ${enabled ? 'enabled' : 'disabled'}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + toast.error('Failed to update tool', { description: message }) + console.error('[ToolsStore] Failed to set status:', message) + } + }, +})) + +// Selector helpers +export const selectEnabledTools = (tools: ToolInfo[]) => + tools.filter(t => t.enabled) + +export const selectEnabledToolsCount = (tools: ToolInfo[]) => + tools.filter(t => t.enabled).length