mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
feat: add settings dialog for switching agents
- Add Settings component with agent list and installation status - Extract agent check utility from CLI for reuse - Add SYSTEM_CHECK_AGENTS IPC channel and handler - Add switchAgent action to useApp hook - Add settings gear button to StatusBar - Show installed agents with version/path, install hints for missing ones Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { ipcMain, dialog } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { DEFAULT_AGENTS } from '../config/defaults'
|
||||
import { checkAgents } from '../utils/agent-check'
|
||||
import type { Conductor } from '../conductor/Conductor'
|
||||
import type { ListSessionsOptions, MulticaSession } from '../../shared/types'
|
||||
|
||||
@@ -122,5 +123,11 @@ export function registerIPCHandlers(conductor: Conductor): void {
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
// --- System handlers ---
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SYSTEM_CHECK_AGENTS, async () => {
|
||||
return checkAgents()
|
||||
})
|
||||
|
||||
console.log('[IPC] All handlers registered')
|
||||
}
|
||||
|
||||
81
src/main/utils/agent-check.ts
Normal file
81
src/main/utils/agent-check.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Utility to check agent installations
|
||||
*/
|
||||
import { execSync } from 'node:child_process'
|
||||
import { platform } from 'node:os'
|
||||
import { DEFAULT_AGENTS } from '../config/defaults'
|
||||
|
||||
export interface AgentCheckResult {
|
||||
id: string
|
||||
name: string
|
||||
command: string
|
||||
installed: boolean
|
||||
path?: string
|
||||
version?: string
|
||||
installHint?: string
|
||||
}
|
||||
|
||||
// Install hints for each agent
|
||||
const INSTALL_HINTS: Record<string, string> = {
|
||||
opencode: 'go install github.com/anomalyco/opencode@latest',
|
||||
codex: 'npm install -g @openai/codex',
|
||||
gemini: 'npm install -g @anthropic-ai/gemini-cli',
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command exists in the system PATH
|
||||
*/
|
||||
export function commandExists(cmd: string): { exists: boolean; path?: string; version?: string } {
|
||||
const isWindows = platform() === 'win32'
|
||||
const whichCmd = isWindows ? 'where' : 'which'
|
||||
|
||||
try {
|
||||
const path = execSync(`${whichCmd} ${cmd}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0]
|
||||
|
||||
// Try to get version
|
||||
let version: string | undefined
|
||||
try {
|
||||
const versionOutput = execSync(`${cmd} --version`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
}).trim()
|
||||
// Extract first line or first meaningful part
|
||||
version = versionOutput.split('\n')[0].slice(0, 50)
|
||||
} catch {
|
||||
// Some commands don't support --version
|
||||
}
|
||||
|
||||
return { exists: true, path, version }
|
||||
} catch {
|
||||
return { exists: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all configured agents and return their installation status
|
||||
*/
|
||||
export function checkAgents(): AgentCheckResult[] {
|
||||
const results: AgentCheckResult[] = []
|
||||
|
||||
for (const [id, config] of Object.entries(DEFAULT_AGENTS)) {
|
||||
const check = commandExists(config.command)
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name: config.name,
|
||||
command: config.command,
|
||||
installed: check.exists,
|
||||
path: check.path,
|
||||
version: check.version,
|
||||
installHint: INSTALL_HINTS[id],
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -44,6 +44,9 @@ const electronAPI: ElectronAPI = {
|
||||
// Dialog
|
||||
selectDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIRECTORY),
|
||||
|
||||
// System
|
||||
checkAgents: () => ipcRenderer.invoke(IPC_CHANNELS.SYSTEM_CHECK_AGENTS),
|
||||
|
||||
// Event listeners
|
||||
onAgentMessage: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, message: unknown) =>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useApp } from './hooks/useApp'
|
||||
import { SessionList, ChatView, MessageInput, StatusBar } from './components'
|
||||
import { SessionList, ChatView, MessageInput, StatusBar, Settings } from './components'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const {
|
||||
@@ -21,6 +21,7 @@ function App(): React.JSX.Element {
|
||||
deleteSession,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
switchAgent,
|
||||
sendPrompt,
|
||||
cancelRequest,
|
||||
clearError,
|
||||
@@ -30,6 +31,9 @@ function App(): React.JSX.Element {
|
||||
const [showNewSession, setShowNewSession] = useState(false)
|
||||
const [newSessionCwd, setNewSessionCwd] = useState('')
|
||||
|
||||
// Settings dialog state
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
// Auto-show new session dialog when agent is running but no session
|
||||
useEffect(() => {
|
||||
if (agentStatus.state === 'running' && !currentSession && sessions.length === 0) {
|
||||
@@ -101,6 +105,7 @@ function App(): React.JSX.Element {
|
||||
currentSession={currentSession}
|
||||
onStartAgent={() => startAgent('opencode')}
|
||||
onStopAgent={stopAgent}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
|
||||
{/* Chat view */}
|
||||
@@ -171,6 +176,14 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<Settings
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
currentAgentId={agentStatus.state === 'running' ? agentStatus.agentId : null}
|
||||
onSwitchAgent={switchAgent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
191
src/renderer/src/components/Settings.tsx
Normal file
191
src/renderer/src/components/Settings.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Settings dialog component
|
||||
* Allows switching agents and viewing installation status
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AgentCheckResult } from '../../../shared/electron-api'
|
||||
|
||||
interface SettingsProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
currentAgentId: string | null
|
||||
onSwitchAgent: (agentId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function Settings({ isOpen, onClose, currentAgentId, onSwitchAgent }: SettingsProps) {
|
||||
const [agents, setAgents] = useState<AgentCheckResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadAgents()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
async function loadAgents() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const results = await window.electronAPI.checkAgents()
|
||||
setAgents(results)
|
||||
} catch (err) {
|
||||
console.error('Failed to check agents:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitch(agentId: string) {
|
||||
if (switching || agentId === currentAgentId) return
|
||||
|
||||
setSwitching(agentId)
|
||||
try {
|
||||
await onSwitchAgent(agentId)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to switch agent:', err)
|
||||
} finally {
|
||||
setSwitching(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-lg bg-[var(--color-surface)] p-6 shadow-xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agent Selection */}
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-sm font-medium text-[var(--color-text-muted)]">
|
||||
Select Coding Agent
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-primary)] border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<AgentItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isActive={agent.id === currentAgentId}
|
||||
isSwitching={switching === agent.id}
|
||||
onSelect={() => handleSwitch(agent.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh button */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={loadAgents}
|
||||
disabled={loading}
|
||||
className="rounded-lg px-4 py-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-[var(--color-primary)] px-4 py-2 font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentItemProps {
|
||||
agent: AgentCheckResult
|
||||
isActive: boolean
|
||||
isSwitching: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
function AgentItem({ agent, isActive, isSwitching, onSelect }: AgentItemProps) {
|
||||
const canSelect = agent.installed && !isActive && !isSwitching
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={canSelect ? onSelect : undefined}
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 transition-colors ${
|
||||
isActive
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
|
||||
: agent.installed
|
||||
? 'cursor-pointer border-[var(--color-border)] hover:border-[var(--color-primary)] hover:bg-[var(--color-surface-hover)]'
|
||||
: 'border-[var(--color-border)] opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className={`h-3 w-3 flex-shrink-0 rounded-full ${
|
||||
agent.installed ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{isActive && (
|
||||
<span className="rounded bg-[var(--color-primary)] px-1.5 py-0.5 text-xs text-white">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-muted)]">
|
||||
{agent.installed ? (
|
||||
<>
|
||||
<span className="font-mono">{agent.command}</span>
|
||||
{agent.version && <span className="ml-2 text-xs">{agent.version}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-400">Not installed</span>
|
||||
)}
|
||||
</div>
|
||||
{!agent.installed && agent.installHint && (
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
Install: <code className="rounded bg-[var(--color-background)] px-1">{agent.installHint}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{isSwitching ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-primary)] border-t-transparent" />
|
||||
) : agent.installed && !isActive ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect()
|
||||
}}
|
||||
className="flex-shrink-0 rounded-lg bg-[var(--color-surface-hover)] px-3 py-1.5 text-sm font-medium transition-colors hover:bg-[var(--color-border)]"
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface StatusBarProps {
|
||||
currentSession: MulticaSession | null
|
||||
onStartAgent: () => void
|
||||
onStopAgent: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
@@ -15,6 +16,7 @@ export function StatusBar({
|
||||
currentSession,
|
||||
onStartAgent,
|
||||
onStopAgent,
|
||||
onOpenSettings,
|
||||
}: StatusBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2">
|
||||
@@ -53,6 +55,28 @@ export function StatusBar({
|
||||
Stop
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Settings button */}
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="rounded p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]"
|
||||
title="Settings"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,3 +2,4 @@ export { SessionList } from './SessionList'
|
||||
export { ChatView } from './ChatView'
|
||||
export { MessageInput } from './MessageInput'
|
||||
export { StatusBar } from './StatusBar'
|
||||
export { Settings } from './Settings'
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AppActions {
|
||||
// Agent actions
|
||||
startAgent: (agentId: string) => Promise<void>
|
||||
stopAgent: () => Promise<void>
|
||||
switchAgent: (agentId: string) => Promise<void>
|
||||
sendPrompt: (content: string) => Promise<void>
|
||||
cancelRequest: () => Promise<void>
|
||||
|
||||
@@ -202,6 +203,22 @@ export function useApp(): AppState & AppActions {
|
||||
}
|
||||
}, [loadAgentStatus])
|
||||
|
||||
const switchAgent = useCallback(async (agentId: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
// Stop current agent first
|
||||
await window.electronAPI.stopAgent()
|
||||
// Clear current session (it's bound to the old agent)
|
||||
setCurrentSession(null)
|
||||
setSessionUpdates([])
|
||||
// Start new agent
|
||||
await window.electronAPI.startAgent(agentId)
|
||||
await loadAgentStatus()
|
||||
} catch (err) {
|
||||
setError(`Failed to switch agent: ${err}`)
|
||||
}
|
||||
}, [loadAgentStatus])
|
||||
|
||||
const sendPrompt = useCallback(async (content: string) => {
|
||||
if (!currentSession) {
|
||||
setError('No active session')
|
||||
@@ -266,6 +283,7 @@ export function useApp(): AppState & AppActions {
|
||||
deleteSession,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
switchAgent,
|
||||
sendPrompt,
|
||||
cancelRequest,
|
||||
clearError,
|
||||
|
||||
13
src/shared/electron-api.d.ts
vendored
13
src/shared/electron-api.d.ts
vendored
@@ -15,6 +15,16 @@ export interface AgentMessage {
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export interface AgentCheckResult {
|
||||
id: string
|
||||
name: string
|
||||
command: string
|
||||
installed: boolean
|
||||
path?: string
|
||||
version?: string
|
||||
installHint?: string
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
// Agent lifecycle
|
||||
startAgent(agentId: string): Promise<{ success: boolean; agentId: string }>
|
||||
@@ -41,6 +51,9 @@ export interface ElectronAPI {
|
||||
// Dialog
|
||||
selectDirectory(): Promise<string | null>
|
||||
|
||||
// System
|
||||
checkAgents(): Promise<AgentCheckResult[]>
|
||||
|
||||
// Event listeners (return unsubscribe function)
|
||||
onAgentMessage(callback: (message: AgentMessage) => void): () => void
|
||||
onAgentStatus(callback: (status: AgentStatus) => void): () => void
|
||||
|
||||
@@ -27,6 +27,9 @@ export const IPC_CHANNELS = {
|
||||
// Dialog
|
||||
DIALOG_SELECT_DIRECTORY: 'dialog:select-directory',
|
||||
|
||||
// System
|
||||
SYSTEM_CHECK_AGENTS: 'system:check-agents',
|
||||
|
||||
// File system (V2)
|
||||
FILE_APPROVAL_REQUEST: 'file:approval-request',
|
||||
FILE_APPROVAL_RESPONSE: 'file:approval-response',
|
||||
|
||||
Reference in New Issue
Block a user