refactor(desktop): migrate to zustand stores and remove hook layer

- Add new stores: skills, tools, cron-jobs, hub
- Remove wrapper hooks: use-channels, use-cron-jobs, use-hub,
  use-provider, use-skills, use-tools, use-heartbeat
- Update all components and pages to use stores directly
- Add 800ms minimum loading time for refresh operations
- Add toast notifications for store actions (success/error feedback)
- Remove unused chat-mode store and remote-chat component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-12 09:38:51 +08:00
parent 3d25aa96f4
commit 6ea6c9fff5
29 changed files with 929 additions and 1389 deletions

View file

@ -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[]

View file

@ -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)

View file

@ -185,13 +185,9 @@ export function ConnectionQRCode({
</div>
{/* Info section */}
<div className="mt-6 text-center space-y-3">
<p className="text-sm text-muted-foreground">
Scan with your phone to connect
</p>
<div className="mt-4 space-y-2">
{/* Expiry timer */}
<div className="flex items-center gap-3 justify-center">
<div className="flex items-center gap-2">
<span
className={`text-xs font-mono ${
isExpiringSoon
@ -203,32 +199,18 @@ export function ConnectionQRCode({
>
{isExpired ? 'Expired' : `Expires in ${formatTime(remainingSeconds)}`}
</span>
{!isExpired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={handleRefresh}
>
<HugeiconsIcon icon={RefreshIcon} className="size-3" />
Refresh
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={isExpired ? handleRefresh : handleCopyLink}
>
<HugeiconsIcon
icon={isExpired ? RefreshIcon : (copied ? CheckmarkCircle02Icon : Copy01Icon)}
className="size-3.5 mr-1"
/>
{isExpired ? 'Refresh' : (copied ? 'Copied!' : 'Copy Link')}
</Button>
</div>
{/* Copy link button */}
<Button
variant="outline"
size="sm"
className="text-xs gap-1.5"
onClick={handleCopyLink}
>
<HugeiconsIcon
icon={copied ? CheckmarkCircle02Icon : Copy01Icon}
className="size-3.5"
/>
{copied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</div>
)

View file

@ -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 (
<div className="h-full flex flex-col overflow-hidden w-full">
{pageState === 'loading' && (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />
Loading...
</div>
)}
{(pageState === 'not-connected' || pageState === 'connecting') && (
<DevicePairing
key={pairingKey}
connectionState={connectionState}
lastError={error}
onConnect={connect}
onCancel={disconnect}
/>
)}
{pageState === 'connected' && client && identity && (
<ConnectedChat
client={client}
hubId={identity.hubId}
agentId={identity.agentId}
/>
)}
</div>
)
}
function ConnectedChat({
client,
hubId,
agentId,
}: {
client: NonNullable<UseGatewayConnectionReturn['client']>
hubId: string
agentId: string
}) {
const chat = useGatewayChat({ client, hubId, agentId })
return <ChatView {...chat} />
}

View file

@ -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<SkillSource, string> = {

View file

@ -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<string, string> = {
fs: 'File System',
runtime: 'Runtime',
web: 'Web',
memory: 'Memory',
subagent: 'Subagent',
cron: 'Cron',
other: 'Other',
}
// Group icons
const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
@ -29,7 +40,6 @@ const GROUP_ICONS: Record<string, typeof FolderOpenIcon> = {
interface ToolListProps {
tools: ToolInfo[]
groups: ToolGroup[]
loading: boolean
error: string | null
onToggleTool: (toolName: string) => Promise<void>
@ -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<Set<string>>(
() => 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 (
<div className="flex items-center justify-center py-12">
@ -126,7 +139,7 @@ export function ToolList({
{/* Tool groups */}
<div className="space-y-2">
{toolsByGroup.map((group) => {
{groups.map((group) => {
const isExpanded = expandedGroups.has(group.id)
const GroupIcon = GROUP_ICONS[group.id] || CodeIcon

View file

@ -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<string, Record<string, Record<string, unknown>> | undefined>
/** Loading state */
loading: boolean
/** Error message if any */
error: string | null
/** Refresh states and config */
refresh: () => Promise<void>
/** 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,
}
}

View file

@ -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<void>
removeJob: (jobId: string) => Promise<void>
refresh: () => Promise<void>
}
export function useCronJobs(): UseCronJobsReturn {
const [jobs, setJobs] = useState<CronJobInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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

View file

@ -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<HeartbeatEvent | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View file

@ -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<void>
/** Refresh Hub info and agents list */
refresh: () => Promise<void>
/** Reconnect to a different Gateway URL */
reconnect: (url: string) => Promise<void>
/** Create a new agent */
createAgent: (id?: string) => Promise<AgentInfo | null>
/** Close an agent */
closeAgent: (id: string) => Promise<boolean>
/** Send a message to an agent */
sendMessage: (agentId: string, content: string) => Promise<boolean>
}
/**
* 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<HubInfo | null>(null)
const [agents, setAgents] = useState<AgentInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<AgentInfo | null> => {
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<boolean> => {
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<boolean> => {
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

View file

@ -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<void>
/** 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,
}
}

View file

@ -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<string, string> = {
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<void>
/** Enable a skill */
enableSkill: (skillId: string) => Promise<void>
/** Disable a skill */
disableSkill: (skillId: string) => Promise<void>
/** Refresh skills list */
refresh: () => Promise<void>
/** 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<SkillInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<SkillGroup[]>(() => {
const sourceOrder: SkillSource[] = ['bundled', 'global', 'profile']
const groupMap = new Map<SkillSource, SkillInfo[]>()
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

View file

@ -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<string, string> = {
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<string, string> = {
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<void>
/** Enable a tool */
enableTool: (toolName: string) => Promise<void>
/** Disable a tool */
disableTool: (toolName: string) => Promise<void>
/** Refresh tools list from main process */
refresh: () => Promise<void>
/** 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<ToolInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<ToolGroup[]>(() => {
const groupMap = new Map<string, string[]>()
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

View file

@ -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<string | undefined>()
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false)

View file

@ -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<string | null>(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 (
<div className="max-w-4xl mx-auto space-y-4">
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<div className="h-full flex flex-col p-6 overflow-auto">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Channels</h1>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : (
<TelegramCard channels={channels} />
)}
{/* 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>
)
}

View file

@ -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 (
<div className="flex items-center gap-1 px-6 py-1 shrink-0">
<NavButton active={mode === 'local'} onClick={() => setMode('local')}>
Local
</NavButton>
<NavButton active={mode === 'remote'} onClick={() => setMode('remote')}>
Remote
</NavButton>
{mode === 'remote' && gateway.pageState === 'connected' && (
<>
<div className="flex-1" />
<button
onClick={gateway.disconnect}
className="text-xs text-muted-foreground hover:text-foreground"
>
Disconnect
</button>
</>
)}
</div>
)
}
function NavButton({
active,
onClick,
children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
active
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
{children}
</button>
)
}
function ModeSelect() {
const setMode = useChatModeStore((s) => s.setMode)
return (
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
<div className="text-center space-y-2">
<h2 className="text-lg font-semibold">Start a Conversation</h2>
<p className="text-sm text-muted-foreground">
Choose how you want to connect
</p>
</div>
<div className="flex flex-col gap-3 w-full max-w-xs">
<Button
size="lg"
onClick={() => setMode('local')}
className="w-full"
>
Local Agent
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
</Button>
<Button
size="lg"
variant="outline"
onClick={() => setMode('remote')}
className="w-full"
>
Remote Agent
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
</Button>
</div>
</div>
)
}
export default function ChatPage() {
const mode = useChatModeStore((s) => s.mode)
const gateway = useGatewayConnection()
return (
<div className="h-full flex flex-col overflow-hidden">
<ModeNav gateway={gateway} />
{mode === 'select' && <ModeSelect />}
{mode === 'local' && <LocalChat />}
<ChatPanel visible={mode === 'remote'}>
<RemoteChat gateway={gateway} />
</ChatPanel>
</div>
)
}
function ChatPanel({
visible,
children,
}: {
visible: boolean
children: React.ReactNode
}) {
return (
<div
className={`flex-1 min-h-0 ${visible ? 'flex flex-col' : 'hidden'}`}
>
{children}
<LocalChat />
</div>
)
}

View file

@ -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 (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Cron Jobs</CardTitle>
<CardDescription>
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
</CardDescription>
</CardHeader>
<CardContent>
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</CardContent>
</Card>
<div className="h-full flex flex-col p-6 overflow-auto">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-lg font-medium">Scheduled Tasks</h1>
<p className="text-sm text-muted-foreground">
Scheduled tasks run automatically at set times. Ask your agent to create one, like "remind me every morning" or "check my inbox daily."
</p>
</div>
{/* Configuration Area */}
<div className="flex-1 min-h-0">
<CronJobList
jobs={jobs}
loading={loading}
error={error}
onToggleJob={toggleJob}
onRemoveJob={removeJob}
onRefresh={refresh}
/>
</div>
</div>
)
}

View file

@ -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 (
<div
className="flex items-center gap-0.5 ml-auto mr-2"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(-1)}
disabled={!canGoBack}
>
<HugeiconsIcon icon={ArrowLeft02Icon} />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(1)}
disabled={!canGoForward}
>
<HugeiconsIcon icon={ArrowRight02Icon} />
</Button>
</div>
)
}
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 (
<header className="h-12 shrink-0 flex items-center px-4">
{/* Drag placeholder for traffic lights when sidebar is collapsed */}
@ -52,8 +104,15 @@ function MainHeader() {
<SidebarTrigger />
{/* Spacer */}
<div className="flex-1" />
{/* Center: Current page */}
<div className="flex-1 flex justify-center">
{currentPage && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HugeiconsIcon icon={currentPage.icon} className="size-4" />
<span>{currentPage.label}</span>
</div>
)}
</div>
{/* Right: Theme toggle */}
<ModeToggle />
@ -68,16 +127,19 @@ export default function Layout() {
<div className="flex h-screen flex-col bg-background text-foreground">
<SidebarProvider className="flex-1 overflow-hidden">
<Sidebar>
{/* Traffic light area */}
{/* Traffic light area with navigation */}
<SidebarHeader
className="h-12 shrink-0"
className="h-12 shrink-0 flex items-center"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
>
<NavigationButtons />
</SidebarHeader>
<SidebarContent>
{/* Main navigation */}
<SidebarGroup>
<SidebarMenu>
{navItems.map((item) => {
<SidebarMenu className="space-y-0.5">
{mainNavItems.map((item) => {
const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path)
@ -85,7 +147,39 @@ export default function Layout() {
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<HugeiconsIcon icon={item.icon} className="size-4" />
<HugeiconsIcon
icon={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>
{/* Configuration */}
<SidebarGroup>
<SidebarGroupLabel>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}>
<HugeiconsIcon
icon={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>

View file

@ -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)

View file

@ -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)

View file

@ -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 (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Skills</CardTitle>
<CardDescription>
Manage agent skills. Skills provide specialized capabilities like Git integration,
code review, and file manipulation. Toggle skills on/off to control agent behavior.
</CardDescription>
</CardHeader>
<CardContent>
<SkillList
skills={skills}
loading={loading}
error={error}
onToggleSkill={toggleSkill}
onRefresh={refresh}
/>
</CardContent>
</Card>
<div className="h-full flex flex-col p-6 overflow-auto">
{/* 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>
)
}

View file

@ -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 (
<div className="max-w-4xl mx-auto">
<Card>
<CardHeader>
<CardTitle>Tools</CardTitle>
<CardDescription>
Configure which tools are available to the Agent. Toggle individual tools on/off.
Changes apply immediately to the running Agent.
</CardDescription>
</CardHeader>
<CardContent>
<ToolList
tools={tools}
groups={groups}
loading={loading}
error={error}
onToggleTool={toggleTool}
onRefresh={refresh}
/>
</CardContent>
</Card>
<div className="h-full flex flex-col p-6 overflow-auto">
{/* 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>
)
}

View file

@ -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<ChannelsStore>()((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<ChannelsStore>()((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<ChannelsStore>()((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<ChannelsStore>()((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<ChannelsStore>()((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<ChannelsStore>()((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 }
}
},

View file

@ -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<ChatModeStore>((set) => ({
mode: "select",
setMode: (mode) => set({ mode }),
}))

View file

@ -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<void>
refresh: () => Promise<void>
toggleJob: (jobId: string) => Promise<void>
removeJob: (jobId: string) => Promise<void>
}
export const useCronJobsStore = create<CronJobsStore>()((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)
}
},
}))

View file

@ -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<void>
refresh: () => Promise<void>
reconnect: (url: string) => Promise<{ ok: boolean; error?: string }>
}
export const useHubStore = create<HubStore>()((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'
}

View file

@ -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<ProviderStore>()((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<ProviderStore>()((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<ProviderStore>()((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)

View file

@ -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<void>
refresh: () => Promise<void>
toggleSkill: (skillId: string) => Promise<void>
setSkillStatus: (skillId: string, enabled: boolean) => Promise<void>
}
export const useSkillsStore = create<SkillsStore>()((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,
})

View file

@ -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<string, string> = {
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<void>
refresh: () => Promise<void>
toggleTool: (toolName: string) => Promise<void>
setToolStatus: (toolName: string, enabled: boolean) => Promise<void>
}
export const useToolsStore = create<ToolsStore>()((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