mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 03:55:27 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
233
src/renderer/src/components/ChatView.tsx
Normal file
233
src/renderer/src/components/ChatView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/renderer/src/components/MessageInput.tsx
Normal file
91
src/renderer/src/components/MessageInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
src/renderer/src/components/SessionList.tsx
Normal file
151
src/renderer/src/components/SessionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
src/renderer/src/components/StatusBar.tsx
Normal file
94
src/renderer/src/components/StatusBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
src/renderer/src/components/index.ts
Normal file
4
src/renderer/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { SessionList } from './SessionList'
|
||||
export { ChatView } from './ChatView'
|
||||
export { MessageInput } from './MessageInput'
|
||||
export { StatusBar } from './StatusBar'
|
||||
273
src/renderer/src/hooks/useApp.ts
Normal file
273
src/renderer/src/hooks/useApp.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user