From 607adeb6672a424254dbaedb3cef07fa1ace61e4 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:50:55 +0800 Subject: [PATCH] feat(desktop): implement local chat with direct IPC and mode switching Add LocalChat component using useLocalChat hook that communicates with the Hub via IPC (no Gateway required). Fix streamId extraction to use event.message.id matching Hub behavior. Fix history to return raw AgentMessageItem[] instead of flattened strings. Add exec approval forwarding over IPC. Use conditional rendering for LocalChat to prevent event leaking from remote sessions. Co-Authored-By: Claude Opus 4.5 --- apps/desktop/electron/electron-env.d.ts | 19 +- apps/desktop/electron/ipc/hub.ts | 69 ++-- apps/desktop/electron/preload.ts | 39 ++- apps/desktop/src/App.tsx | 3 +- apps/desktop/src/components/local-chat.tsx | 48 +++ apps/desktop/src/components/remote-chat.tsx | 51 +++ apps/desktop/src/hooks/use-devices.ts | 10 + apps/desktop/src/hooks/use-hub.ts | 11 + apps/desktop/src/hooks/use-local-chat.ts | 340 +++++--------------- apps/desktop/src/pages/chat.tsx | 264 +++++++-------- apps/desktop/src/pages/layout.tsx | 15 +- apps/desktop/src/stores/chat-mode.ts | 13 + 12 files changed, 445 insertions(+), 437 deletions(-) create mode 100644 apps/desktop/src/components/local-chat.tsx create mode 100644 apps/desktop/src/components/remote-chat.tsx create mode 100644 apps/desktop/src/stores/chat-mode.ts diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 7777fdf2..d111bb44 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -101,6 +101,16 @@ interface LocalChatEvent { } } +interface LocalChatApproval { + approvalId: string + agentId: string + command: string + cwd?: string + riskLevel: 'safe' | 'needs-review' | 'dangerous' + riskReasons: string[] + expiresAtMs: number +} + interface ProviderStatus { id: string name: string @@ -140,6 +150,10 @@ interface ElectronAPI { deviceConfirmResponse: (deviceId: string, allowed: boolean) => void listDevices: () => Promise revokeDevice: (deviceId: string) => Promise<{ ok: boolean }> + onConnectionStateChanged: (callback: (state: string) => void) => void + offConnectionStateChanged: () => void + onDevicesChanged: (callback: () => void) => void + offDevicesChanged: () => void } tools: { list: () => Promise @@ -179,10 +193,13 @@ interface ElectronAPI { localChat: { subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> unsubscribe: (agentId: string) => Promise<{ ok: boolean }> - getHistory: (agentId: string) => Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; agentId: string }> }> + getHistory: (agentId: string) => Promise<{ messages: unknown[] }> send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }> + resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }> onEvent: (callback: (event: LocalChatEvent) => void) => void offEvent: () => void + onApproval: (callback: (approval: LocalChatApproval) => void) => void + offApproval: () => void } } diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 75d1f1b6..fe06cf69 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -9,18 +9,6 @@ import { Hub } from '../../../../src/hub/hub.js' import type { ConnectionState } from '@multica/sdk' import type { AsyncAgent } from '../../../../src/agent/async-agent.js' -/** - * Extract plain text from AgentMessage content (string or content block array). - */ -function extractTextContent(content: unknown): string { - if (typeof content === 'string') return content - if (!Array.isArray(content)) return '' - return content - .filter((c: { type?: string }) => c.type === 'text') - .map((c: { text?: string }) => c.text ?? '') - .join('') -} - // Singleton Hub instance let hub: Hub | null = null let defaultAgentId: string | null = null @@ -290,9 +278,10 @@ export function registerHubIpcHandlers(): void { if (!shouldForward) return - // Track stream ID for message grouping + // Track stream ID for message grouping (extract from event.message.id, same as Hub.beginStream) if (event.type === 'message_start') { - currentStreamId = (event as { id?: string }).id ?? `stream-${Date.now()}` + const msgId = (event as { message?: { id?: string } }).message?.id + currentStreamId = msgId ?? `stream-${Date.now()}` safeLog(`[IPC] Starting stream: ${currentStreamId}`) } @@ -310,6 +299,14 @@ export function registerHubIpcHandlers(): void { }) ipcAgentSubscriptions.set(agentId, unsubscribe) + + // Register local approval handler so exec approval requests route via IPC + h.setLocalApprovalHandler(agentId, (payload) => { + if (!mainWindowRef || mainWindowRef.isDestroyed()) return + safeLog(`[IPC] Sending approval request to renderer: ${payload.approvalId}`) + mainWindowRef.webContents.send('localChat:approval', payload) + }) + safeLog(`[IPC] Local chat subscribed to agent: ${agentId}`) return { ok: true } @@ -324,13 +321,15 @@ export function registerHubIpcHandlers(): void { unsubscribe() } ipcAgentSubscriptions.delete(agentId) + getHub().removeLocalApprovalHandler(agentId) safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`) return { ok: true } }) /** * Get message history for local chat. - * Returns messages in the same format as useMessagesStore. + * Returns raw AgentMessageItem[] so the renderer can render content blocks, + * tool results, thinking blocks, etc. — same format as the Gateway RPC. */ ipcMain.handle('localChat:getHistory', async (_event, agentId: string) => { const h = getHub() @@ -340,17 +339,8 @@ export function registerHubIpcHandlers(): void { } try { - const sessionMessages = agent.getMessages() - const messages = sessionMessages - .filter((m) => m.role === 'user' || m.role === 'assistant') - .map((m, i) => ({ - id: `history-${i}-${Date.now()}`, - role: m.role as 'user' | 'assistant', - content: extractTextContent((m as { content?: unknown }).content), - agentId, - })) - .filter((m) => m.content.length > 0) - + await agent.ensureInitialized() + const messages = agent.getMessages() return { messages } } catch { return { messages: [] } @@ -381,6 +371,15 @@ export function registerHubIpcHandlers(): void { return { ok: true } }) + /** + * Resolve an exec approval request for local chat. + */ + ipcMain.handle('localChat:resolveExecApproval', async (_event, approvalId: string, decision: string) => { + const h = getHub() + const ok = h.resolveExecApproval(approvalId, decision as 'allow-once' | 'allow-always' | 'deny') + return { ok } + }) + /** * Register a one-time token for device verification. * Called by the QR code component when a token is generated or refreshed. @@ -404,7 +403,12 @@ export function registerHubIpcHandlers(): void { */ ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => { const h = getHub() - return { ok: h.deviceStore.revokeDevice(deviceId) } + const ok = h.deviceStore.revokeDevice(deviceId) + // Notify renderer that device list changed + if (ok && mainWindowRef && !mainWindowRef.isDestroyed()) { + mainWindowRef.webContents.send('hub:devices-changed') + } + return { ok } }) } @@ -440,10 +444,21 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi pendingConfirms.set(deviceId, (allowed: boolean) => { clearTimeout(timeout) resolve(allowed) + // Notify renderer that device list changed when a device is approved + if (allowed && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('hub:devices-changed') + } }) mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta) }) }) + + // Forward connection state changes to renderer + h.onConnectionStateChange((state) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('hub:connection-state-changed', state) + } + }) } /** diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 16093ac0..dcd84e97 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -82,6 +82,17 @@ export interface LocalChatEvent { } } +// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk) +export interface LocalChatApproval { + approvalId: string + agentId: string + command: string + cwd?: string + riskLevel: 'safe' | 'needs-review' | 'dangerous' + riskReasons: string[] + expiresAtMs: number +} + // Available style options export const AGENT_STYLES = ['concise', 'warm', 'playful', 'professional'] as const export type AgentStyle = (typeof AGENT_STYLES)[number] @@ -117,6 +128,18 @@ const electronAPI = { }, listDevices: () => ipcRenderer.invoke('hub:listDevices'), revokeDevice: (deviceId: string) => ipcRenderer.invoke('hub:revokeDevice', deviceId), + onConnectionStateChanged: (callback: (state: string) => void) => { + ipcRenderer.on('hub:connection-state-changed', (_event, state: string) => callback(state)) + }, + offConnectionStateChanged: () => { + ipcRenderer.removeAllListeners('hub:connection-state-changed') + }, + onDevicesChanged: (callback: () => void) => { + ipcRenderer.on('hub:devices-changed', () => callback()) + }, + offDevicesChanged: () => { + ipcRenderer.removeAllListeners('hub:devices-changed') + }, }, // Tools management @@ -184,11 +207,13 @@ const electronAPI = { subscribe: (agentId: string) => ipcRenderer.invoke('localChat:subscribe', agentId), /** Unsubscribe from agent events */ unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId), - /** Get message history for local chat */ - getHistory: (agentId: string): Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; agentId: string }> }> => - ipcRenderer.invoke('localChat:getHistory', agentId), + /** Get message history for local chat (returns raw AgentMessageItem[]) */ + getHistory: (agentId: string) => ipcRenderer.invoke('localChat:getHistory', agentId), /** Send message to agent via direct IPC (no Gateway) */ send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content), + /** Resolve an exec approval request */ + resolveExecApproval: (approvalId: string, decision: string) => + ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision), /** Listen for agent events */ onEvent: (callback: (event: LocalChatEvent) => void) => { ipcRenderer.on('localChat:event', (_event, data: LocalChatEvent) => callback(data)) @@ -197,6 +222,14 @@ const electronAPI = { offEvent: () => { ipcRenderer.removeAllListeners('localChat:event') }, + /** Listen for exec approval requests */ + onApproval: (callback: (approval: LocalChatApproval) => void) => { + ipcRenderer.on('localChat:approval', (_event, data: LocalChatApproval) => callback(data)) + }, + /** Remove approval listener */ + offApproval: () => { + ipcRenderer.removeAllListeners('localChat:approval') + }, }, } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index df6ae8b1..b67f362f 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,7 +1,6 @@ import { createHashRouter, RouterProvider } from 'react-router-dom' import Layout from './pages/layout' import HomePage from './pages/home' -import ChatPage from './pages/chat' import ToolsPage from './pages/tools' import SkillsPage from './pages/skills' @@ -11,7 +10,7 @@ const router = createHashRouter([ element: , children: [ { index: true, element: }, - { path: 'chat', element: }, + { path: 'chat' }, { path: 'tools', element: }, { path: 'skills', element: }, ], diff --git a/apps/desktop/src/components/local-chat.tsx b/apps/desktop/src/components/local-chat.tsx new file mode 100644 index 00000000..95f1f81b --- /dev/null +++ b/apps/desktop/src/components/local-chat.tsx @@ -0,0 +1,48 @@ +import { Loading } from '@multica/ui/components/ui/loading' +import { ChatView } from '@multica/ui/components/chat-view' +import { useLocalChat } from '../hooks/use-local-chat' + +export function LocalChat() { + const { + agentId, + initError, + messages, + streamingIds, + isLoading, + isLoadingHistory, + error, + pendingApprovals, + sendMessage, + resolveApproval, + } = useLocalChat() + + if (initError) { + return ( +
+ {initError} +
+ ) + } + + if (!agentId) { + return ( +
+ + Initializing... +
+ ) + } + + return ( + + ) +} diff --git a/apps/desktop/src/components/remote-chat.tsx b/apps/desktop/src/components/remote-chat.tsx new file mode 100644 index 00000000..cb691c02 --- /dev/null +++ b/apps/desktop/src/components/remote-chat.tsx @@ -0,0 +1,51 @@ +import { Loading } from '@multica/ui/components/ui/loading' +import { ChatView } from '@multica/ui/components/chat-view' +import { DevicePairing } from '@multica/ui/components/device-pairing' +import { useGatewayChat } from '@multica/hooks/use-gateway-chat' +import type { UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection' + +export function RemoteChat({ gateway }: { gateway: UseGatewayConnectionReturn }) { + const { pageState, connectionState, error, client, identity, pairingKey, connect, disconnect } = gateway + + return ( +
+ {pageState === 'loading' && ( +
+ + Loading... +
+ )} + + {(pageState === 'not-connected' || pageState === 'connecting') && ( + + )} + + {pageState === 'connected' && client && identity && ( + + )} +
+ ) +} + +function ConnectedChat({ + client, + hubId, + agentId, +}: { + client: NonNullable + hubId: string + agentId: string +}) { + const chat = useGatewayChat({ client, hubId, agentId }) + return +} diff --git a/apps/desktop/src/hooks/use-devices.ts b/apps/desktop/src/hooks/use-devices.ts index 189e2a67..3a7c2936 100644 --- a/apps/desktop/src/hooks/use-devices.ts +++ b/apps/desktop/src/hooks/use-devices.ts @@ -53,5 +53,15 @@ export function useDevices(): UseDevicesReturn { refresh() }, [refresh]) + // Subscribe to device list changes pushed from main process + useEffect(() => { + window.electronAPI?.hub.onDevicesChanged(() => { + refresh() + }) + return () => { + window.electronAPI?.hub.offDevicesChanged() + } + }, [refresh]) + return { devices, loading, refresh, revokeDevice } } diff --git a/apps/desktop/src/hooks/use-hub.ts b/apps/desktop/src/hooks/use-hub.ts index e3ec6a53..e54e2fd5 100644 --- a/apps/desktop/src/hooks/use-hub.ts +++ b/apps/desktop/src/hooks/use-hub.ts @@ -88,6 +88,17 @@ export function useHub(): UseHubReturn { initHub() }, [initHub]) + // Subscribe to connection state changes pushed from main process + useEffect(() => { + const handler = (state: string) => { + setHubInfo((prev) => prev ? { ...prev, connectionState: state as HubInfo['connectionState'] } : prev) + } + window.electronAPI?.hub.onConnectionStateChanged(handler) + return () => { + window.electronAPI?.hub.offConnectionStateChanged() + } + }, []) + // Refresh Hub info and agents const refresh = useCallback(async () => { try { diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index 6ebd1cdb..f69fddd6 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -1,277 +1,111 @@ -/** - * Hook for local direct chat with agent via IPC (no Gateway required). - * - * Returns UseChatReturn-compatible shape so it can be plugged directly - * into the shared component. All state is local (useState), - * no Zustand store involved. - */ import { useState, useEffect, useCallback, useRef } from 'react' -import { v7 as uuidv7 } from 'uuid' -import type { ContentBlock } from '@multica/sdk' -import type { UseChatReturn, Message, ToolStatus, ChatError } from '@multica/hooks/use-chat' -import type { ApprovalDecision } from '@multica/sdk' +import { useChat } from '@multica/hooks/use-chat' +import type { + StreamPayload, + ExecApprovalRequestPayload, + ApprovalDecision, +} from '@multica/sdk' -// Stable empty array to avoid re-renders in consumers -const EMPTY_APPROVALS: never[] = [] - -function toContentBlocks(content: unknown): ContentBlock[] { - if (typeof content === 'string') { - return content ? [{ type: 'text', text: content }] : [] - } - if (Array.isArray(content)) return content as ContentBlock[] - return [] -} - -function extractContent(event: { message?: { content?: unknown } }): ContentBlock[] { - if (!event.message?.content) return [] - return Array.isArray(event.message.content) - ? (event.message.content as ContentBlock[]) - : [] -} - -/** - * Provides local IPC chat returning the same UseChatReturn shape as - * the gateway-based useChat hook. - * - * Agent ID is fetched internally from hub.getStatus() — no parameters needed. - */ -export function useLocalChat(): UseChatReturn { - const [messages, setMessages] = useState([]) - const [streamingIds, setStreamingIds] = useState>(new Set()) +export function useLocalChat() { + const chat = useChat() + const [agentId, setAgentId] = useState(null) const [isLoading, setIsLoading] = useState(false) const [isLoadingHistory, setIsLoadingHistory] = useState(true) - const [error, setError] = useState(null) - - const agentIdRef = useRef(null) + const [initError, setInitError] = useState(null) + const initRef = useRef(false) + // Initialize hub and get default agent ID useEffect(() => { - let cancelled = false + if (initRef.current) return + initRef.current = true - async function init() { - // 1. Discover agentId from hub - let agentId: string - try { - const status = await window.electronAPI.hub.getStatus() - if (!status.defaultAgent?.agentId) { - if (!cancelled) { - setError({ code: 'NO_AGENT', message: 'No local agent available' }) - setIsLoadingHistory(false) - } - return - } - agentId = status.defaultAgent.agentId - agentIdRef.current = agentId - } catch { - if (!cancelled) { - setError({ code: 'HUB_ERROR', message: 'Failed to connect to hub' }) + window.electronAPI.hub.init() + .then((result) => { + const r = result as { defaultAgentId?: string } + console.log('[LocalChat] hub.init → defaultAgentId:', r.defaultAgentId) + if (r.defaultAgentId) { + setAgentId(r.defaultAgentId) + } else { + setInitError('No default agent available') setIsLoadingHistory(false) } - return - } - - // 2. Subscribe to agent events - const subResult = await window.electronAPI.localChat.subscribe(agentId) - if (cancelled) return - if (subResult.error) { - setError({ code: 'SUBSCRIBE_FAILED', message: subResult.error }) - setIsLoadingHistory(false) - return - } - - // 3. Load history - try { - const result = await window.electronAPI.localChat.getHistory(agentId) - if (!cancelled && result.messages?.length > 0) { - setMessages( - result.messages.map((m) => ({ - id: m.id ?? uuidv7(), - role: m.role as Message['role'], - content: toContentBlocks(m.content), - agentId, - })), - ) - } - } catch { - // History load is best-effort - } - - if (!cancelled) setIsLoadingHistory(false) - - // 4. Listen for streaming events - window.electronAPI.localChat.onEvent((ev) => { - if (cancelled || ev.agentId !== agentIdRef.current) return - - // Error event - if (ev.type === 'error') { - setError({ - code: 'AGENT_ERROR', - message: ev.content ?? 'Unknown error', - }) - setIsLoading(false) - return - } - - const agentEvent = ev.event - const streamId = ev.streamId - if (!agentEvent || !streamId) return - - switch (agentEvent.type) { - case 'message_start': { - const content = extractContent(agentEvent) - const newMsg: Message = { - id: streamId, - role: 'assistant', - content: content.length ? content : [], - agentId: ev.agentId, - } - setMessages((prev) => [...prev, newMsg]) - setStreamingIds((prev) => new Set(prev).add(streamId)) - setIsLoading(true) - break - } - case 'message_update': { - const content = extractContent(agentEvent) - setMessages((prev) => - prev.map((m) => (m.id === streamId ? { ...m, content } : m)), - ) - break - } - case 'message_end': { - const content = extractContent(agentEvent) - const stopReason = - 'message' in agentEvent - ? (agentEvent.message as { stopReason?: string })?.stopReason - : undefined - - setMessages((prev) => - prev.map((m) => { - if (m.id === streamId) return { ...m, content, stopReason } - // Interrupt running tools belonging to the same agent - if ( - m.role === 'toolResult' && - m.toolStatus === 'running' && - m.agentId === ev.agentId - ) { - return { ...m, toolStatus: 'interrupted' as ToolStatus } - } - return m - }), - ) - setStreamingIds((prev) => { - const next = new Set(prev) - next.delete(streamId) - return next - }) - setIsLoading(false) - break - } - case 'tool_execution_start': { - const toolEvent = agentEvent as { - type: 'tool_execution_start' - toolCallId?: string - toolName?: string - args?: Record - } - const toolMsg: Message = { - id: uuidv7(), - role: 'toolResult', - content: [], - agentId: ev.agentId, - toolCallId: toolEvent.toolCallId, - toolName: toolEvent.toolName, - toolArgs: toolEvent.args, - toolStatus: 'running', - isError: false, - } - setMessages((prev) => [...prev, toolMsg]) - break - } - case 'tool_execution_end': { - const toolEvent = agentEvent as { - type: 'tool_execution_end' - toolCallId?: string - result?: unknown - isError?: boolean - } - setMessages((prev) => - prev.map((m) => - m.role === 'toolResult' && m.toolCallId === toolEvent.toolCallId - ? { - ...m, - toolStatus: (toolEvent.isError ? 'error' : 'success') as ToolStatus, - isError: toolEvent.isError ?? false, - content: - toolEvent.result != null - ? [ - { - type: 'text' as const, - text: - typeof toolEvent.result === 'string' - ? toolEvent.result - : JSON.stringify(toolEvent.result), - }, - ] - : [], - } - : m, - ), - ) - break - } - } }) - } - - init() - - return () => { - cancelled = true - window.electronAPI.localChat.offEvent() - const id = agentIdRef.current - if (id) window.electronAPI.localChat.unsubscribe(id) - } + .catch((err: Error) => { + setInitError(err.message) + setIsLoadingHistory(false) + }) }, []) - const sendMessage = useCallback((text: string) => { - const trimmed = text.trim() - if (!trimmed) return - - const agentId = agentIdRef.current + // Subscribe to events + fetch history once agentId is available + useEffect(() => { if (!agentId) return - // Add user message locally - setMessages((prev) => [ - ...prev, - { - id: uuidv7(), - role: 'user', - content: [{ type: 'text', text: trimmed }], - agentId, - }, - ]) - setIsLoading(true) - setError(null) + // Subscribe to agent events + window.electronAPI.localChat.subscribe(agentId).catch(() => {}) - // Send via IPC - window.electronAPI.localChat.send(agentId, trimmed).then((result) => { - if (result.error) { - setError({ code: 'SEND_FAILED', message: result.error }) - setIsLoading(false) - } + // Listen for stream events + window.electronAPI.localChat.onEvent((data) => { + // Cast IPC event to StreamPayload (same shape: { agentId, streamId, event }) + const payload = data as unknown as StreamPayload + if (!payload.event) return + + chat.handleStream(payload) + if (payload.event.type === 'message_start') setIsLoading(true) + if (payload.event.type === 'message_end') setIsLoading(false) }) - }, []) - const resolveApproval = useCallback((_approvalId: string, _decision: ApprovalDecision) => { - // Exec approvals not supported on local IPC yet — no-op - }, []) + // Listen for exec approval requests + window.electronAPI.localChat.onApproval((approval) => { + chat.addApproval(approval as ExecApprovalRequestPayload) + }) + + // Fetch history + window.electronAPI.localChat.getHistory(agentId) + .then((result) => { + console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, sample:', result.messages?.[0]) + if (result.messages?.length) { + chat.setHistory(result.messages as never[], agentId) + } + }) + .catch(() => {}) + .finally(() => setIsLoadingHistory(false)) + + return () => { + window.electronAPI.localChat.offEvent() + window.electronAPI.localChat.offApproval() + window.electronAPI.localChat.unsubscribe(agentId).catch(() => {}) + } + }, [agentId]) + + const sendMessage = useCallback( + (text: string) => { + const trimmed = text.trim() + if (!trimmed || !agentId) return + chat.addUserMessage(trimmed, agentId) + chat.setError(null) + window.electronAPI.localChat.send(agentId, trimmed).catch(() => {}) + setIsLoading(true) + }, + [agentId], + ) + + const resolveApproval = useCallback( + (approvalId: string, decision: ApprovalDecision) => { + chat.removeApproval(approvalId) + window.electronAPI.localChat.resolveExecApproval(approvalId, decision).catch(() => {}) + }, + [], + ) return { - messages, - streamingIds, + agentId, + initError, + messages: chat.messages, + streamingIds: chat.streamingIds, isLoading, isLoadingHistory, - error, - pendingApprovals: EMPTY_APPROVALS, + error: chat.error, + pendingApprovals: chat.pendingApprovals, sendMessage, resolveApproval, } diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx index 58f2dfc7..1581bdf6 100644 --- a/apps/desktop/src/pages/chat.tsx +++ b/apps/desktop/src/pages/chat.tsx @@ -1,162 +1,128 @@ -/** - * Chat Page - supports both Local (IPC) and Remote (Gateway) modes - * - * Local mode: useLocalChat() → ChatView (direct IPC to embedded Hub) - * Remote mode: useGatewayConnection() + useChat() → DevicePairing / ChatView - */ -import { useState, useEffect } from 'react' import { Button } from '@multica/ui/components/ui/button' -import { Loading } from '@multica/ui/components/ui/loading' -import { ChatView } from '@multica/ui/components/chat-view' -import { DevicePairing } from '@multica/ui/components/device-pairing' -import { useGatewayConnection } from '@multica/hooks/use-gateway-connection' -import { useChat } from '@multica/hooks/use-chat' -import { useLocalChat } from '../hooks/use-local-chat' +import { RemoteChat } from '../components/remote-chat' +import { LocalChat } from '../components/local-chat' +import { useChatModeStore } from '../stores/chat-mode' +import { useGatewayConnection, type UseGatewayConnectionReturn } from '@multica/hooks/use-gateway-connection' -type ChatMode = 'select' | 'local' | 'remote' +function ModeNav({ gateway }: { gateway: UseGatewayConnectionReturn }) { + const { mode, setMode } = useChatModeStore() + + if (mode === 'select') return null + + return ( +
+ setMode('local')}> + Local + + setMode('remote')}> + Remote + + + {mode === 'remote' && gateway.pageState === 'connected' && ( + <> +
+ + + )} +
+ ) +} + +function NavButton({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +function ModeSelect() { + const setMode = useChatModeStore((s) => s.setMode) + + return ( +
+
+

Start a Conversation

+

+ Choose how you want to connect +

+
+ +
+ + + +
+
+ ) +} export default function ChatPage() { - const [mode, setMode] = useState('select') - const [defaultAgentId, setDefaultAgentId] = useState(null) - - // Get default agent ID on mount (only for enabling the Local button) - useEffect(() => { - const loadAgentId = async () => { - const status = await window.electronAPI.hub.getStatus() - if (status.defaultAgent?.agentId) { - setDefaultAgentId(status.defaultAgent.agentId) - } - } - loadAgentId() - }, []) - - if (mode === 'select') { - return ( -
-
-

Start a Conversation

-

- Choose how you want to connect -

-
- -
- - - -
- - {!defaultAgentId && ( -

- Waiting for local agent to initialize... -

- )} -
- ) - } - - if (mode === 'local') { - return setMode('select')} /> - } - - return setMode('select')} /> -} - -/** - * Local Chat View - Direct IPC communication with agent. - * useLocalChat() fetches agentId internally and returns UseChatReturn shape. - */ -function LocalChatView({ onBack }: { onBack: () => void }) { - const chat = useLocalChat() + const mode = useChatModeStore((s) => s.mode) + const gateway = useGatewayConnection() return ( -
- +
+ + + {mode === 'select' && } + + {mode === 'local' && } + + + +
) } -/** - * Remote Chat View - Gateway connection to external Hub. - * Mirrors the web app structure: DevicePairing → ConnectedRemoteChat. - */ -function RemoteChatView({ onBack }: { onBack: () => void }) { - const { - pageState, - connectionState, - identity, - error, - client, - pairingKey, - connect, - disconnect, - } = useGatewayConnection() - - const handleDisconnect = () => { - disconnect() - onBack() - } - - return ( -
- {pageState === 'loading' && ( -
- - Loading... -
- )} - - {(pageState === 'not-connected' || pageState === 'connecting') && ( - - )} - - {pageState === 'connected' && client && identity && ( - - )} -
- ) -} - -/** Thin wrapper that wires useChat to the shared ChatView */ -function ConnectedRemoteChat({ - client, - hubId, - agentId, - onDisconnect, +function ChatPanel({ + visible, + children, }: { - client: NonNullable['client']> - hubId: string - agentId: string - onDisconnect: () => void + visible: boolean + children: React.ReactNode }) { - const chat = useChat({ client, hubId, agentId }) - - return + return ( +
+ {children} +
+ ) } diff --git a/apps/desktop/src/pages/layout.tsx b/apps/desktop/src/pages/layout.tsx index 8e6d472c..85e97e15 100644 --- a/apps/desktop/src/pages/layout.tsx +++ b/apps/desktop/src/pages/layout.tsx @@ -11,6 +11,7 @@ import { } from '@hugeicons/core-free-icons' import { cn } from '@multica/ui/lib/utils' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' +import ChatPage from './chat' const tabs = [ { path: '/', label: 'Home', icon: Home01Icon, exact: true }, @@ -59,8 +60,18 @@ export default function Layout() { {/* Content */} -
- +
+ {/* ChatPage is always mounted (cached), hidden via CSS */} +
+ +
+ + {/* Other routes render normally via Outlet */} + {location.pathname !== '/chat' && ( +
+ +
+ )}
diff --git a/apps/desktop/src/stores/chat-mode.ts b/apps/desktop/src/stores/chat-mode.ts new file mode 100644 index 00000000..4737f363 --- /dev/null +++ b/apps/desktop/src/stores/chat-mode.ts @@ -0,0 +1,13 @@ +import { create } from "zustand" + +export type ChatMode = "select" | "local" | "remote" + +interface ChatModeStore { + mode: ChatMode + setMode: (mode: ChatMode) => void +} + +export const useChatModeStore = create((set) => ({ + mode: "select", + setMode: (mode) => set({ mode }), +}))