feat: add complete UI for chat interface

Components:
- SessionList: sidebar with session list, create/delete/select
- ChatView: message display with tool call cards
- MessageInput: textarea with send/cancel buttons
- StatusBar: agent status and session info

State Management:
- useApp hook: centralized state for sessions, agent, messages
- Real-time streaming via IPC events

Main Process:
- Wire conductor events to renderer via IPC
- Forward session updates to BrowserWindow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiayuan
2026-01-14 03:31:39 +08:00
parent 4b10e12b18
commit 856ada3d44
9 changed files with 1020 additions and 58 deletions

View File

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

View File

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

View File

@@ -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<void> => {
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 (
<div className="flex h-screen flex-col">
{/* Title bar drag region */}
<div className="flex h-screen flex-col bg-[var(--color-background)] text-[var(--color-text)]">
{/* Title bar drag region (macOS) */}
<div className="titlebar-drag-region h-8 flex-shrink-0" />
{/* Error banner */}
{error && (
<div className="flex items-center justify-between bg-red-600 px-4 py-2 text-sm text-white">
<span>{error}</span>
<button onClick={clearError} className="hover:underline">
Dismiss
</button>
</div>
)}
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar placeholder */}
<aside className="w-64 flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<h2 className="mb-4 text-sm font-semibold text-[var(--color-text-muted)]">Sessions</h2>
<p className="text-sm text-[var(--color-text-muted)]">No sessions yet</p>
</aside>
{/* Sidebar */}
<SessionList
sessions={sessions}
currentSessionId={currentSession?.id ?? null}
onSelect={handleSelectSession}
onDelete={deleteSession}
onNewSession={handleNewSession}
/>
{/* Chat area placeholder */}
{/* Main area */}
<main className="flex flex-1 flex-col">
{/* Chat messages area */}
<div className="flex flex-1 items-center justify-center overflow-y-auto p-4">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold">Multica</h1>
<p className="mb-6 text-[var(--color-text-muted)]">
A GUI client for ACP-compatible coding agents
</p>
<button
onClick={handleTestIPC}
className="titlebar-no-drag rounded-lg bg-[var(--color-primary)] px-4 py-2 font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)]"
>
Test IPC Connection
</button>
<p className="mt-4 text-sm text-[var(--color-text-muted)]">
Press F12 to open DevTools
</p>
</div>
</div>
{/* Status bar */}
<StatusBar
agentStatus={agentStatus}
currentSession={currentSession}
onStartAgent={() => startAgent('opencode')}
onStopAgent={stopAgent}
/>
{/* Input area placeholder */}
<div className="flex-shrink-0 border-t border-[var(--color-border)] p-4">
<div className="mx-auto flex max-w-3xl gap-2">
<input
type="text"
placeholder="Type a message..."
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-primary)]"
/>
<button className="rounded-lg bg-[var(--color-primary)] px-4 py-2 font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)]">
Send
</button>
</div>
</div>
{/* Chat view */}
<ChatView updates={sessionUpdates} isProcessing={isProcessing} />
{/* Input */}
<MessageInput
onSend={sendPrompt}
onCancel={cancelRequest}
isProcessing={isProcessing}
disabled={!currentSession || agentStatus.state !== 'running'}
/>
</main>
</div>
{/* New session dialog */}
{showNewSession && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-[var(--color-surface)] p-6 shadow-xl">
<h2 className="mb-4 text-lg font-semibold">New Session</h2>
<label className="mb-2 block text-sm text-[var(--color-text-muted)]">
Working Directory
</label>
<input
type="text"
value={newSessionCwd}
onChange={(e) => 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)
}}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowNewSession(false)}
className="rounded-lg px-4 py-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
Cancel
</button>
<button
onClick={handleCreateSession}
disabled={!newSessionCwd.trim()}
className="rounded-lg bg-[var(--color-primary)] px-4 py-2 font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)] disabled:opacity-50"
>
Create
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h1 className="mb-2 text-3xl font-bold">Multica</h1>
<p className="text-[var(--color-text-muted)]">
Start a conversation with your coding agent
</p>
</div>
</div>
)
}
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-4 p-4">
{messages.map((msg, idx) => (
<MessageBubble key={idx} message={msg} />
))}
{isProcessing && (
<div className="flex items-center gap-2 text-[var(--color-text-muted)]">
<LoadingDots />
<span className="text-sm">Agent is thinking...</span>
</div>
)}
<div ref={bottomRef} />
</div>
</div>
)
}
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<string, ToolCall>()
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 (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-lg px-4 py-2 ${
isUser
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-surface)]'
}`}
>
{/* Tool calls */}
{message.toolCalls.length > 0 && (
<div className="mb-2 space-y-2">
{message.toolCalls.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
</div>
)}
{/* Text content */}
{message.content && (
<div className="whitespace-pre-wrap text-sm">{message.content}</div>
)}
</div>
</div>
)
}
interface ToolCallCardProps {
toolCall: ToolCall
}
function ToolCallCard({ toolCall }: ToolCallCardProps) {
const statusIcon =
toolCall.status === 'completed' ? (
<span className="text-green-500"></span>
) : toolCall.status === 'running' ? (
<LoadingDots />
) : (
<span className="text-yellow-500"></span>
)
return (
<div className="rounded border border-[var(--color-border)] bg-[var(--color-background)] p-2 text-xs">
<div className="flex items-center gap-2">
{statusIcon}
<span className="font-medium">{toolCall.title}</span>
{toolCall.kind && (
<span className="text-[var(--color-text-muted)]">({toolCall.kind})</span>
)}
</div>
{toolCall.input && (
<pre className="mt-1 max-h-24 overflow-auto rounded bg-black/20 p-1 text-[var(--color-text-muted)]">
{toolCall.input.slice(0, 200)}
{toolCall.input.length > 200 && '...'}
</pre>
)}
</div>
)
}
function LoadingDots() {
return (
<span className="inline-flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: '0ms' }} />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: '150ms' }} />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: '300ms' }} />
</span>
)
}

View File

@@ -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<HTMLTextAreaElement>(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 (
<div className="border-t border-[var(--color-border)] p-4">
<div className="mx-auto flex max-w-3xl gap-2">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? 'Select or create a session first' : placeholder}
disabled={disabled}
rows={1}
className="flex-1 resize-none rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-50"
/>
{isProcessing ? (
<button
onClick={onCancel}
className="flex-shrink-0 rounded-lg bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-700"
>
Cancel
</button>
) : (
<button
onClick={handleSubmit}
disabled={disabled || !value.trim()}
className="flex-shrink-0 rounded-lg bg-[var(--color-primary)] px-4 py-2 font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)] disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
)}
</div>
{/* Hint */}
<div className="mx-auto mt-1 max-w-3xl text-center text-xs text-[var(--color-text-muted)]">
Press Enter to send, Shift+Enter for new line
</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
/**
* Session list sidebar component
*/
import type { MulticaSession } from '../../../shared/types'
interface SessionListProps {
sessions: MulticaSession[]
currentSessionId: string | null
onSelect: (sessionId: string) => void
onDelete: (sessionId: string) => void
onNewSession: () => void
}
function formatDate(iso: string): string {
const date = new Date(iso)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
function getSessionTitle(session: MulticaSession): string {
if (session.title) return session.title
// Use last part of working directory
const parts = session.workingDirectory.split('/')
return parts[parts.length - 1] || session.workingDirectory
}
export function SessionList({
sessions,
currentSessionId,
onSelect,
onDelete,
onNewSession,
}: SessionListProps) {
return (
<aside className="flex h-full w-64 flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] p-3">
<h2 className="text-sm font-semibold text-[var(--color-text-muted)]">Sessions</h2>
<button
onClick={onNewSession}
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]"
title="New Session"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto p-2">
{sessions.length === 0 ? (
<p className="p-2 text-center text-sm text-[var(--color-text-muted)]">
No sessions yet
</p>
) : (
<ul className="space-y-1">
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isActive={session.id === currentSessionId}
onSelect={() => onSelect(session.id)}
onDelete={() => onDelete(session.id)}
/>
))}
</ul>
)}
</div>
</aside>
)
}
interface SessionItemProps {
session: MulticaSession
isActive: boolean
onSelect: () => void
onDelete: () => void
}
function SessionItem({ session, isActive, onSelect, onDelete }: SessionItemProps) {
return (
<li>
<button
onClick={onSelect}
className={`group flex w-full items-start gap-2 rounded-lg p-2 text-left transition-colors ${
isActive
? 'bg-[var(--color-primary)] text-white'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
{/* Status indicator */}
<span
className={`mt-1.5 h-2 w-2 flex-shrink-0 rounded-full ${
session.status === 'active'
? 'bg-green-500'
: session.status === 'error'
? 'bg-red-500'
: 'bg-gray-500'
}`}
/>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{getSessionTitle(session)}
</div>
<div
className={`truncate text-xs ${
isActive ? 'text-white/70' : 'text-[var(--color-text-muted)]'
}`}
>
{session.agentId} · {formatDate(session.updatedAt)}
</div>
</div>
{/* Delete button */}
<button
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className={`flex-shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100 ${
isActive
? 'hover:bg-white/20'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
title="Delete session"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</button>
</li>
)
}

View File

@@ -0,0 +1,94 @@
/**
* Status bar component - shows agent status and current session info
*/
import type { AgentStatus, MulticaSession } from '../../../shared/types'
interface StatusBarProps {
agentStatus: AgentStatus
currentSession: MulticaSession | null
onStartAgent: () => void
onStopAgent: () => void
}
export function StatusBar({
agentStatus,
currentSession,
onStartAgent,
onStopAgent,
}: StatusBarProps) {
return (
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2">
{/* Left: Session info */}
<div className="flex items-center gap-3">
{currentSession ? (
<>
<span className="text-sm font-medium">
{currentSession.title || currentSession.workingDirectory.split('/').pop()}
</span>
<span className="text-xs text-[var(--color-text-muted)]">
{currentSession.workingDirectory}
</span>
</>
) : (
<span className="text-sm text-[var(--color-text-muted)]">No session selected</span>
)}
</div>
{/* Right: Agent status */}
<div className="flex items-center gap-3">
<AgentStatusBadge status={agentStatus} />
{agentStatus.state === 'stopped' ? (
<button
onClick={onStartAgent}
className="rounded bg-[var(--color-primary)] px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-[var(--color-primary-dark)]"
>
Start Agent
</button>
) : agentStatus.state === 'running' ? (
<button
onClick={onStopAgent}
className="rounded bg-[var(--color-surface-hover)] px-3 py-1 text-xs font-medium transition-colors hover:bg-red-600 hover:text-white"
>
Stop
</button>
) : null}
</div>
</div>
)
}
interface AgentStatusBadgeProps {
status: AgentStatus
}
function AgentStatusBadge({ status }: AgentStatusBadgeProps) {
let dotColor = 'bg-gray-500'
let text = 'Stopped'
switch (status.state) {
case 'starting':
dotColor = 'bg-yellow-500 animate-pulse'
text = `Starting ${status.agentId}...`
break
case 'running':
dotColor = 'bg-green-500'
text = status.agentId
break
case 'error':
dotColor = 'bg-red-500'
text = 'Error'
break
case 'stopped':
dotColor = 'bg-gray-500'
text = 'Stopped'
break
}
return (
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
<span className="text-xs text-[var(--color-text-muted)]">{text}</span>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { SessionList } from './SessionList'
export { ChatView } from './ChatView'
export { MessageInput } from './MessageInput'
export { StatusBar } from './StatusBar'

View File

@@ -0,0 +1,273 @@
/**
* Main application state hook
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import type {
MulticaSession,
AgentStatus,
StoredSessionUpdate,
} from '../../../shared/types'
export interface AppState {
// Sessions
sessions: MulticaSession[]
currentSession: MulticaSession | null
sessionUpdates: StoredSessionUpdate[]
// Agent
agentStatus: AgentStatus
isProcessing: boolean
// UI
error: string | null
}
export interface AppActions {
// Session actions
loadSessions: () => Promise<void>
createSession: (cwd: string) => Promise<void>
selectSession: (sessionId: string) => Promise<void>
deleteSession: (sessionId: string) => Promise<void>
// Agent actions
startAgent: (agentId: string) => Promise<void>
stopAgent: () => Promise<void>
sendPrompt: (content: string) => Promise<void>
cancelRequest: () => Promise<void>
// UI actions
clearError: () => void
}
export function useApp(): AppState & AppActions {
// State
const [sessions, setSessions] = useState<MulticaSession[]>([])
const [currentSession, setCurrentSession] = useState<MulticaSession | null>(null)
const [sessionUpdates, setSessionUpdates] = useState<StoredSessionUpdate[]>([])
const [agentStatus, setAgentStatus] = useState<AgentStatus>({ state: 'stopped' })
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Track streaming text for current message
const streamingTextRef = useRef<string>('')
// Load sessions on mount
useEffect(() => {
loadSessions()
loadAgentStatus()
}, [])
// Subscribe to agent events
useEffect(() => {
const unsubMessage = window.electronAPI.onAgentMessage((message) => {
// Accumulate streaming text
for (const content of message.content) {
if (content.type === 'text') {
streamingTextRef.current += content.text
}
}
// Create/update the streaming message in updates
setSessionUpdates((prev) => {
const now = new Date().toISOString()
// Find or create streaming message update
const streamingUpdate: StoredSessionUpdate = {
timestamp: now,
update: {
sessionId: message.sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: streamingTextRef.current },
},
},
}
// Replace last streaming update or add new one
const lastIdx = prev.length - 1
if (lastIdx >= 0 && !message.done) {
const last = prev[lastIdx]
if (
last.update.update &&
'sessionUpdate' in last.update.update &&
last.update.update.sessionUpdate === 'agent_message_chunk'
) {
return [...prev.slice(0, lastIdx), streamingUpdate]
}
}
if (message.done) {
streamingTextRef.current = ''
setIsProcessing(false)
}
return [...prev, streamingUpdate]
})
})
const unsubStatus = window.electronAPI.onAgentStatus((status) => {
setAgentStatus(status)
})
const unsubError = window.electronAPI.onAgentError((err) => {
setError(err.message)
setIsProcessing(false)
})
return () => {
unsubMessage()
unsubStatus()
unsubError()
}
}, [])
// Actions
const loadSessions = useCallback(async () => {
try {
const list = await window.electronAPI.listSessions()
setSessions(list)
} catch (err) {
setError(`Failed to load sessions: ${err}`)
}
}, [])
const loadAgentStatus = useCallback(async () => {
try {
const status = await window.electronAPI.getAgentStatus()
setAgentStatus(status)
} catch (err) {
console.error('Failed to get agent status:', err)
}
}, [])
const createSession = useCallback(async (cwd: string) => {
try {
setError(null)
const session = await window.electronAPI.createSession(cwd)
setCurrentSession(session)
setSessionUpdates([])
await loadSessions()
} catch (err) {
setError(`Failed to create session: ${err}`)
}
}, [loadSessions])
const selectSession = useCallback(async (sessionId: string) => {
try {
setError(null)
// Resume session (creates new ACP session, loads history)
const session = await window.electronAPI.resumeSession(sessionId)
setCurrentSession(session)
// Load session data for history
const data = await window.electronAPI.getSession(sessionId)
if (data) {
setSessionUpdates(data.updates)
}
} catch (err) {
setError(`Failed to select session: ${err}`)
}
}, [])
const deleteSession = useCallback(async (sessionId: string) => {
try {
setError(null)
await window.electronAPI.deleteSession(sessionId)
if (currentSession?.id === sessionId) {
setCurrentSession(null)
setSessionUpdates([])
}
await loadSessions()
} catch (err) {
setError(`Failed to delete session: ${err}`)
}
}, [currentSession, loadSessions])
const startAgent = useCallback(async (agentId: string) => {
try {
setError(null)
await window.electronAPI.startAgent(agentId)
await loadAgentStatus()
} catch (err) {
setError(`Failed to start agent: ${err}`)
}
}, [loadAgentStatus])
const stopAgent = useCallback(async () => {
try {
setError(null)
await window.electronAPI.stopAgent()
await loadAgentStatus()
} catch (err) {
setError(`Failed to stop agent: ${err}`)
}
}, [loadAgentStatus])
const sendPrompt = useCallback(async (content: string) => {
if (!currentSession) {
setError('No active session')
return
}
try {
setError(null)
setIsProcessing(true)
streamingTextRef.current = ''
// Add user message to updates (use a custom marker for UI display)
// 'user_message' is a custom type not in ACP SDK, used for UI purposes only
const userUpdate = {
timestamp: new Date().toISOString(),
update: {
sessionId: currentSession.agentSessionId,
update: {
sessionUpdate: 'user_message',
content: { type: 'text', text: content },
},
},
} as unknown as StoredSessionUpdate
setSessionUpdates((prev) => [...prev, userUpdate])
await window.electronAPI.sendPrompt(currentSession.id, content)
setIsProcessing(false)
} catch (err) {
setError(`Failed to send prompt: ${err}`)
setIsProcessing(false)
}
}, [currentSession])
const cancelRequest = useCallback(async () => {
if (!currentSession) return
try {
await window.electronAPI.cancelRequest(currentSession.id)
setIsProcessing(false)
} catch (err) {
setError(`Failed to cancel: ${err}`)
}
}, [currentSession])
const clearError = useCallback(() => {
setError(null)
}, [])
return {
// State
sessions,
currentSession,
sessionUpdates,
agentStatus,
isProcessing,
error,
// Actions
loadSessions,
createSession,
selectSession,
deleteSession,
startAgent,
stopAgent,
sendPrompt,
cancelRequest,
clearError,
}
}