diff --git a/src/main/index.ts b/src/main/index.ts index bc179e125e..3f97b136dd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,13 +4,15 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { registerIPCHandlers } from './ipc/handlers' import { Conductor } from './conductor/Conductor' +import { IPC_CHANNELS } from '../shared/ipc-channels' // Global conductor instance let conductor: Conductor +let mainWindow: BrowserWindow | null = null -function createWindow(): void { +function createWindow(): BrowserWindow { // Create the browser window. - const mainWindow = new BrowserWindow({ + const window = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, @@ -27,11 +29,11 @@ function createWindow(): void { } }) - mainWindow.on('ready-to-show', () => { - mainWindow.show() + window.on('ready-to-show', () => { + window.show() }) - mainWindow.webContents.setWindowOpenHandler((details) => { + window.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) @@ -39,10 +41,12 @@ function createWindow(): void { // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + window.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + window.loadFile(join(__dirname, '../renderer/index.html')) } + + return window } // This method will be called when Electron has finished @@ -58,14 +62,35 @@ app.whenReady().then(async () => { optimizer.watchWindowShortcuts(window) }) - // Initialize conductor - conductor = new Conductor() + // Initialize conductor with event handlers + conductor = new Conductor({ + events: { + onSessionUpdate: (params) => { + // Forward session updates to renderer + if (mainWindow && !mainWindow.isDestroyed()) { + const update = params.update + // Convert to AgentMessage format for renderer + if ('sessionUpdate' in update) { + const content: Array<{ type: 'text'; text: string }> = [] + if (update.sessionUpdate === 'agent_message_chunk' && update.content?.type === 'text') { + content.push({ type: 'text', text: update.content.text }) + } + mainWindow.webContents.send(IPC_CHANNELS.AGENT_MESSAGE, { + sessionId: params.sessionId, + content, + done: false, + }) + } + } + }, + }, + }) await conductor.initialize() // Register IPC handlers registerIPCHandlers(conductor) - createWindow() + mainWindow = createWindow() app.on('activate', function () { // On macOS it's common to re-create a window in the app when the diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 8a9bfe5932..ab262bfbf0 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -48,12 +48,12 @@ export function registerIPCHandlers(conductor: Conductor): void { const agent = conductor.getCurrentAgent() const isRunning = conductor.isAgentRunning() if (!isRunning || !agent) { - return { state: 'stopped' } + return { state: 'stopped' as const } } return { - state: 'running', + state: 'running' as const, agentId: agent.id, - agentName: agent.name, + sessionCount: 0, // TODO: track session count } }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 616edb1c38..107ac26751 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,62 +1,153 @@ +/** + * Main App component + */ +import { useState } from 'react' +import { useApp } from './hooks/useApp' +import { SessionList, ChatView, MessageInput, StatusBar } from './components' + function App(): React.JSX.Element { - const handleTestIPC = async (): Promise => { - try { - const session = await window.electronAPI.createSession('/tmp') - console.log('Session created:', session) - } catch (error) { - console.error('IPC Error:', error) + const { + // State + sessions, + currentSession, + sessionUpdates, + agentStatus, + isProcessing, + error, + + // Actions + createSession, + selectSession, + deleteSession, + startAgent, + stopAgent, + sendPrompt, + cancelRequest, + clearError, + } = useApp() + + // New session dialog state + const [showNewSession, setShowNewSession] = useState(false) + const [newSessionCwd, setNewSessionCwd] = useState('') + + const handleNewSession = () => { + setNewSessionCwd(process.cwd?.() || '') + setShowNewSession(true) + } + + const handleCreateSession = async () => { + if (!newSessionCwd.trim()) return + + // Ensure agent is running + if (agentStatus.state !== 'running') { + await startAgent('opencode') } + + await createSession(newSessionCwd.trim()) + setShowNewSession(false) + setNewSessionCwd('') + } + + const handleSelectSession = async (sessionId: string) => { + // Ensure agent is running + if (agentStatus.state !== 'running') { + const session = sessions.find((s) => s.id === sessionId) + if (session) { + await startAgent(session.agentId) + } + } + await selectSession(sessionId) } return ( -
- {/* Title bar drag region */} +
+ {/* Title bar drag region (macOS) */}
+ {/* Error banner */} + {error && ( +
+ {error} + +
+ )} + {/* Main content */}
- {/* Sidebar placeholder */} - + {/* Sidebar */} + - {/* Chat area placeholder */} + {/* Main area */}
- {/* Chat messages area */} -
-
-

Multica

-

- A GUI client for ACP-compatible coding agents -

- -

- Press F12 to open DevTools -

-
-
+ {/* Status bar */} + startAgent('opencode')} + onStopAgent={stopAgent} + /> - {/* Input area placeholder */} -
-
- - -
-
+ {/* Chat view */} + + + {/* Input */} +
+ + {/* New session dialog */} + {showNewSession && ( +
+
+

New Session

+ + + setNewSessionCwd(e.target.value)} + placeholder="/path/to/project" + className="mb-4 w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-2 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-primary)]" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateSession() + if (e.key === 'Escape') setShowNewSession(false) + }} + /> + +
+ + +
+
+
+ )}
) } diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx new file mode 100644 index 0000000000..4004735265 --- /dev/null +++ b/src/renderer/src/components/ChatView.tsx @@ -0,0 +1,233 @@ +/** + * Chat view component - displays messages and tool calls + */ +import { useEffect, useRef } from 'react' +import type { StoredSessionUpdate } from '../../../shared/types' + +interface ChatViewProps { + updates: StoredSessionUpdate[] + isProcessing: boolean +} + +export function ChatView({ updates, isProcessing }: ChatViewProps) { + const bottomRef = useRef(null) + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [updates]) + + // Group updates into messages + const messages = groupUpdatesIntoMessages(updates) + + if (messages.length === 0) { + return ( +
+
+

Multica

+

+ Start a conversation with your coding agent +

+
+
+ ) + } + + return ( +
+
+ {messages.map((msg, idx) => ( + + ))} + + {isProcessing && ( +
+ + Agent is thinking... +
+ )} + +
+
+
+ ) +} + +interface Message { + role: 'user' | 'assistant' + content: string + toolCalls: ToolCall[] +} + +interface ToolCall { + id: string + title: string + status: string + kind?: string + input?: string + output?: string +} + +function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] { + const messages: Message[] = [] + let currentAssistantContent = '' + let currentToolCalls: ToolCall[] = [] + const toolCallMap = new Map() + + for (const stored of updates) { + const update = stored.update.update + if (!update || !('sessionUpdate' in update)) continue + + switch (update.sessionUpdate) { + case 'user_message' as string: + // Flush any pending assistant message + if (currentAssistantContent || currentToolCalls.length > 0) { + messages.push({ + role: 'assistant', + content: currentAssistantContent, + toolCalls: currentToolCalls, + }) + currentAssistantContent = '' + currentToolCalls = [] + toolCallMap.clear() + } + // Add user message + { + const userUpdate = update as { content?: { type: string; text: string } } + if (userUpdate.content?.type === 'text') { + messages.push({ + role: 'user', + content: userUpdate.content.text, + toolCalls: [], + }) + } + } + break + + case 'agent_message_chunk': + if ('content' in update && update.content?.type === 'text') { + currentAssistantContent = update.content.text + } + break + + case 'tool_call': + if ('toolCallId' in update) { + const toolCall: ToolCall = { + id: update.toolCallId, + title: update.title || 'Tool Call', + status: update.status || 'pending', + kind: update.kind, + input: typeof update.rawInput === 'string' + ? update.rawInput + : update.rawInput + ? JSON.stringify(update.rawInput, null, 2) + : undefined, + } + toolCallMap.set(update.toolCallId, toolCall) + currentToolCalls = Array.from(toolCallMap.values()) + } + break + + case 'tool_call_update': + if ('toolCallId' in update && toolCallMap.has(update.toolCallId)) { + const existing = toolCallMap.get(update.toolCallId)! + if (update.status) existing.status = update.status + if (update.rawOutput) { + existing.output = typeof update.rawOutput === 'string' + ? update.rawOutput + : JSON.stringify(update.rawOutput, null, 2) + } + currentToolCalls = Array.from(toolCallMap.values()) + } + break + } + } + + // Flush any remaining assistant content + if (currentAssistantContent || currentToolCalls.length > 0) { + messages.push({ + role: 'assistant', + content: currentAssistantContent, + toolCalls: currentToolCalls, + }) + } + + return messages +} + +interface MessageBubbleProps { + message: Message +} + +function MessageBubble({ message }: MessageBubbleProps) { + const isUser = message.role === 'user' + + return ( +
+
+ {/* Tool calls */} + {message.toolCalls.length > 0 && ( +
+ {message.toolCalls.map((tc) => ( + + ))} +
+ )} + + {/* Text content */} + {message.content && ( +
{message.content}
+ )} +
+
+ ) +} + +interface ToolCallCardProps { + toolCall: ToolCall +} + +function ToolCallCard({ toolCall }: ToolCallCardProps) { + const statusIcon = + toolCall.status === 'completed' ? ( + + ) : toolCall.status === 'running' ? ( + + ) : ( + + ) + + return ( +
+
+ {statusIcon} + {toolCall.title} + {toolCall.kind && ( + ({toolCall.kind}) + )} +
+ {toolCall.input && ( +
+          {toolCall.input.slice(0, 200)}
+          {toolCall.input.length > 200 && '...'}
+        
+ )} +
+ ) +} + +function LoadingDots() { + return ( + + + + + + ) +} diff --git a/src/renderer/src/components/MessageInput.tsx b/src/renderer/src/components/MessageInput.tsx new file mode 100644 index 0000000000..a19f272e10 --- /dev/null +++ b/src/renderer/src/components/MessageInput.tsx @@ -0,0 +1,91 @@ +/** + * Message input component + */ +import { useState, useRef, useEffect } from 'react' + +interface MessageInputProps { + onSend: (content: string) => void + onCancel: () => void + isProcessing: boolean + disabled: boolean + placeholder?: string +} + +export function MessageInput({ + onSend, + onCancel, + isProcessing, + disabled, + placeholder = 'Type a message...', +}: MessageInputProps) { + const [value, setValue] = useState('') + const textareaRef = useRef(null) + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current + if (textarea) { + textarea.style.height = 'auto' + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` + } + }, [value]) + + // Focus on mount + useEffect(() => { + textareaRef.current?.focus() + }, []) + + const handleSubmit = () => { + const trimmed = value.trim() + if (!trimmed || disabled || isProcessing) return + + onSend(trimmed) + setValue('') + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSubmit() + } + } + + return ( +
+
+