From 26df74738fface00d0f3f76a836ec3f1b6b2ec8e Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Wed, 4 Feb 2026 16:46:37 +0800 Subject: [PATCH] feat(desktop): add dual-mode chat page (Local/Remote) Chat page now supports two modes: - Local: Direct IPC to agent in same Electron process - Remote: WebSocket via Gateway to external Hub Both modes reuse the same UI components and useMessagesStore. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/src/pages/chat.tsx | 238 +++++++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx index 52d406edb..8bf461be5 100644 --- a/apps/desktop/src/pages/chat.tsx +++ b/apps/desktop/src/pages/chat.tsx @@ -1,5 +1,239 @@ -import { Chat } from '@multica/ui/components/chat' +/** + * Chat Page - supports both Local (IPC) and Remote (Gateway) modes + * + * Both modes use the same useMessagesStore and Chat UI components. + * The difference is only in the transport layer: + * - Local: Direct IPC to agent in the same Electron process + * - Remote: WebSocket via Gateway to external Hub + */ +import { useState, useEffect, useCallback, useRef } from 'react' +import { Button } from '@multica/ui/components/ui/button' +import { ChatInput } from '@multica/ui/components/chat-input' +import { MessageList } from '@multica/ui/components/message-list' +import { ConnectPrompt } from '@multica/ui/components/connect-prompt' +import { useMessagesStore, useConnectionStore, useAutoConnect } from '@multica/store' +import { useScrollFade } from '@multica/ui/hooks/use-scroll-fade' +import { useAutoScroll } from '@multica/ui/hooks/use-auto-scroll' +import { useLocalChat } from '../hooks/use-local-chat' + +type ChatMode = 'select' | 'local' | 'remote' export default function ChatPage() { - return + const [mode, setMode] = useState('select') + const [defaultAgentId, setDefaultAgentId] = useState(null) + + // Get default agent ID on mount + useEffect(() => { + const loadAgentId = async () => { + const status = await window.electronAPI.hub.getStatus() + if (status.defaultAgent?.agentId) { + setDefaultAgentId(status.defaultAgent.agentId) + } + } + loadAgentId() + }, []) + + // Clear messages when switching modes + const handleModeChange = (newMode: ChatMode) => { + useMessagesStore.getState().clearMessages() + setMode(newMode) + } + + // Mode selection screen + if (mode === 'select') { + return ( +
+
+

Start a Conversation

+

+ Choose how you want to connect +

+
+ +
+ + + +
+ + {!defaultAgentId && ( +

+ Waiting for local agent to initialize... +

+ )} +
+ ) + } + + // Local chat mode - uses useLocalChat hook that bridges to useMessagesStore + if (mode === 'local' && defaultAgentId) { + return handleModeChange('select')} /> + } + + // Remote chat mode - uses Gateway connection + return handleModeChange('select')} /> +} + +/** + * Local Chat View - Direct IPC communication with agent + * Uses useLocalChat hook which bridges IPC events to useMessagesStore + */ +function LocalChatView({ agentId, onBack }: { agentId: string; onBack: () => void }) { + const { isConnected, isLoading, sendMessage, disconnect } = useLocalChat({ agentId }) + + // Use same stores as Gateway mode + const messages = useMessagesStore((s) => s.messages) + const streamingIds = useMessagesStore((s) => s.streamingIds) + + const mainRef = useRef(null) + const fadeStyle = useScrollFade(mainRef) + useAutoScroll(mainRef) + + const handleDisconnect = useCallback(() => { + disconnect() + onBack() + }, [disconnect, onBack]) + + return ( +
+ {/* Header */} +
+
+ + Local Agent + +
+ +
+ + {/* Messages - same component as Gateway mode */} +
+ {messages.length === 0 ? ( +
+ Send a message to start the conversation +
+ ) : ( + + )} +
+ + {/* Input - same component as Gateway mode */} +
+ +
+
+ ) +} + +/** + * Remote Chat View - Gateway connection to external Hub + * Same as the original Chat component + */ +function RemoteChatView({ onBack }: { onBack: () => void }) { + const { loading } = useAutoConnect() + + const agentId = useConnectionStore((s) => s.agentId) + const gwState = useConnectionStore((s) => s.connectionState) + const hubId = useConnectionStore((s) => s.hubId) + + const messages = useMessagesStore((s) => s.messages) + const streamingIds = useMessagesStore((s) => s.streamingIds) + + const isConnected = gwState === 'registered' && !!hubId && !!agentId + + const handleSend = useCallback((text: string) => { + const { hubId, agentId, send, connectionState } = useConnectionStore.getState() + if (connectionState !== 'registered' || !hubId || !agentId) return + useMessagesStore.getState().sendMessage(text, { hubId, agentId, send }) + }, []) + + const handleDisconnect = useCallback(() => { + useConnectionStore.getState().disconnect() + onBack() + }, [onBack]) + + const mainRef = useRef(null) + const fadeStyle = useScrollFade(mainRef) + useAutoScroll(mainRef) + + return ( +
+ {/* Header */} +
+
+ + Remote Agent +
+ {isConnected && ( + + )} +
+ + {/* Messages */} +
+ {loading ? ( +
+ Loading... +
+ ) : !isConnected ? ( + + ) : messages.length === 0 ? ( +
+ Send a message to start the conversation +
+ ) : ( + + )} +
+ + {/* Input */} + +
+ ) }