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 }), +}))