diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 00f42d30..0f085b69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,7 @@ "dependencies": { "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", + "@multica/hooks": "workspace:*", "@multica/sdk": "workspace:*", "@multica/store": "workspace:*", "@multica/ui": "workspace:*", diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index ebe19d0c..6ebd1cdb 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -1,147 +1,278 @@ /** * Hook for local direct chat with agent via IPC (no Gateway required). * - * This hook bridges IPC events to useMessagesStore, allowing the Chat component - * to work identically in both local IPC and remote Gateway modes. + * 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 { useMessagesStore } from '@multica/store' +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' -interface UseLocalChatOptions { - agentId: string +// 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 [] } -interface UseLocalChatReturn { - isConnected: boolean - isLoading: boolean - sendMessage: (content: string) => void - disconnect: () => void +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 that uses the same useMessagesStore as Gateway mode. - * This enables full Chat component reuse. + * 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({ agentId }: UseLocalChatOptions): UseLocalChatReturn { - const [isConnected, setIsConnected] = useState(false) +export function useLocalChat(): UseChatReturn { + const [messages, setMessages] = useState([]) + const [streamingIds, setStreamingIds] = useState>(new Set()) const [isLoading, setIsLoading] = useState(false) - const currentStreamRef = useRef(null) + const [isLoadingHistory, setIsLoadingHistory] = useState(true) + const [error, setError] = useState(null) + + const agentIdRef = useRef(null) - // Subscribe to agent events on mount useEffect(() => { - if (!agentId) return + let cancelled = false - const subscribe = async () => { - const result = await window.electronAPI.localChat.subscribe(agentId) - if (result.ok) { - setIsConnected(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' }) + setIsLoadingHistory(false) + } + return } - } - subscribe() + // 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 + } - // Load message history from agent session - const loadHistory = async () => { + // 3. Load history try { const result = await window.electronAPI.localChat.getHistory(agentId) - if (result.messages && result.messages.length > 0) { - // Normalize: IPC may return content as string, store expects ContentBlock[] - useMessagesStore.getState().loadMessages( - result.messages.map((m: Record) => ({ - ...m, - content: typeof m.content === 'string' - ? (m.content ? [{ type: 'text' as const, text: m.content }] : []) - : (m.content ?? []), - })) as import('@multica/store').Message[] + 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 + } + } + }) } - loadHistory() - // Listen for events and route to useMessagesStore - window.electronAPI.localChat.onEvent((event) => { - if (event.agentId !== agentId) return + init() - const store = useMessagesStore.getState() + return () => { + cancelled = true + window.electronAPI.localChat.offEvent() + const id = agentIdRef.current + if (id) window.electronAPI.localChat.unsubscribe(id) + } + }, []) - // Handle error - if (event.type === 'error') { - store.addAssistantMessage(event.content ?? 'Unknown error', agentId) - setIsLoading(false) - return - } + const sendMessage = useCallback((text: string) => { + const trimmed = text.trim() + if (!trimmed) return - // Handle agent events - same logic as connection-store.ts - const agentEvent = event.event - const streamId = event.streamId - if (!agentEvent || !streamId) return + const agentId = agentIdRef.current + if (!agentId) return - if (agentEvent.type === 'message_start') { - currentStreamRef.current = streamId - store.startStream(streamId, agentId) - const content = extractContentFromAgentEvent(agentEvent) - if (content.length) store.appendStream(streamId, content) - } else if (agentEvent.type === 'message_update') { - const content = extractContentFromAgentEvent(agentEvent) - if (content.length && currentStreamRef.current) { - store.appendStream(currentStreamRef.current, content) - } - } else if (agentEvent.type === 'message_end') { - const content = extractContentFromAgentEvent(agentEvent) - if (currentStreamRef.current) { - store.endStream(currentStreamRef.current, content) - currentStreamRef.current = null - } + // Add user message locally + setMessages((prev) => [ + ...prev, + { + id: uuidv7(), + role: 'user', + content: [{ type: 'text', text: trimmed }], + agentId, + }, + ]) + setIsLoading(true) + setError(null) + + // Send via IPC + window.electronAPI.localChat.send(agentId, trimmed).then((result) => { + if (result.error) { + setError({ code: 'SEND_FAILED', message: result.error }) setIsLoading(false) } }) + }, []) - return () => { - window.electronAPI.localChat.offEvent() - window.electronAPI.localChat.unsubscribe(agentId) - setIsConnected(false) - } - }, [agentId]) - - const sendMessage = useCallback( - async (content: string) => { - if (!content.trim() || !agentId || isLoading) return - - // Add user message to store (same as Gateway mode) - useMessagesStore.getState().addUserMessage(content.trim(), agentId) - setIsLoading(true) - - // Send via IPC - const result = await window.electronAPI.localChat.send(agentId, content.trim()) - if (result.error) { - useMessagesStore.getState().addAssistantMessage(`Error: ${result.error}`, agentId) - setIsLoading(false) - } - }, - [agentId, isLoading] - ) - - const disconnect = useCallback(() => { - useMessagesStore.getState().clearMessages() - setIsConnected(false) - setIsLoading(false) + const resolveApproval = useCallback((_approvalId: string, _decision: ApprovalDecision) => { + // Exec approvals not supported on local IPC yet — no-op }, []) return { - isConnected, + messages, + streamingIds, isLoading, + isLoadingHistory, + error, + pendingApprovals: EMPTY_APPROVALS, sendMessage, - disconnect, + resolveApproval, } } - -/** Extract content blocks from AgentEvent message */ -function extractContentFromAgentEvent(event: { message?: { content?: unknown } }): ContentBlock[] { - if (!event.message?.content) return [] - const content = event.message.content - return Array.isArray(content) ? content as ContentBlock[] : [] -} diff --git a/apps/desktop/src/pages/chat.tsx b/apps/desktop/src/pages/chat.tsx index 8bf461be..58f2dfc7 100644 --- a/apps/desktop/src/pages/chat.tsx +++ b/apps/desktop/src/pages/chat.tsx @@ -1,19 +1,16 @@ /** * 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 + * Local mode: useLocalChat() → ChatView (direct IPC to embedded Hub) + * Remote mode: useGatewayConnection() + useChat() → DevicePairing / ChatView */ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect } 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 { 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' type ChatMode = 'select' | 'local' | 'remote' @@ -22,7 +19,7 @@ export default function ChatPage() { const [mode, setMode] = useState('select') const [defaultAgentId, setDefaultAgentId] = useState(null) - // Get default agent ID on mount + // Get default agent ID on mount (only for enabling the Local button) useEffect(() => { const loadAgentId = async () => { const status = await window.electronAPI.hub.getStatus() @@ -33,13 +30,6 @@ export default function ChatPage() { loadAgentId() }, []) - // Clear messages when switching modes - const handleModeChange = (newMode: ChatMode) => { - useMessagesStore.getState().clearMessages() - setMode(newMode) - } - - // Mode selection screen if (mode === 'select') { return (
@@ -53,7 +43,7 @@ export default function ChatPage() {
- 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 + * Remote Chat View - Gateway connection to external Hub. + * Mirrors the web app structure: DevicePairing → ConnectedRemoteChat. */ function RemoteChatView({ onBack }: { onBack: () => void }) { - const { loading } = useAutoConnect() + const { + pageState, + connectionState, + identity, + error, + client, + pairingKey, + connect, + disconnect, + } = useGatewayConnection() - 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() + const handleDisconnect = () => { + disconnect() onBack() - }, [onBack]) - - const mainRef = useRef(null) - const fadeStyle = useScrollFade(mainRef) - useAutoScroll(mainRef) + } return (
- {/* Header */} -
-
- - Remote Agent + {pageState === 'loading' && ( +
+ + Loading...
- {isConnected && ( - - )} -
+ )} - {/* Messages */} -
- {loading ? ( -
- Loading... -
- ) : !isConnected ? ( - - ) : messages.length === 0 ? ( -
- Send a message to start the conversation -
- ) : ( - - )} -
- - {/* Input */} -
- -
+ )} + + {pageState === 'connected' && client && identity && ( + + )}
) } + +/** Thin wrapper that wires useChat to the shared ChatView */ +function ConnectedRemoteChat({ + client, + hubId, + agentId, + onDisconnect, +}: { + client: NonNullable['client']> + hubId: string + agentId: string + onDisconnect: () => void +}) { + const chat = useChat({ client, hubId, agentId }) + + return +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6341b511..e418556c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,13 +34,13 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -153,6 +153,9 @@ importers: '@hugeicons/react': specifier: ^1.1.4 version: 1.1.4(react@19.2.3) + '@multica/hooks': + specifier: workspace:* + version: link:../../packages/hooks '@multica/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -454,10 +457,10 @@ importers: devDependencies: '@mariozechner/pi-agent-core': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) '@mariozechner/pi-ai': specifier: ^0.50.3 - version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 @@ -11593,12 +11596,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)': + '@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@mariozechner/clipboard': 0.3.0 '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) - '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76) + '@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-tui': 0.50.3 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2