refactor(desktop): reuse shared ChatView, DevicePairing, and hooks in chat page

Replace Zustand-based message/connection stores with local state hooks.
useLocalChat now returns UseChatReturn shape with internal agentId discovery,
tool execution events, and error handling. Remote mode uses shared
useGatewayConnection + useChat + DevicePairing from packages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 15:43:39 +08:00
parent b896ac402a
commit 22bd392cb0
4 changed files with 319 additions and 261 deletions

View file

@ -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:*",

View file

@ -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 <ChatView> 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<Message[]>([])
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(false)
const currentStreamRef = useRef<string | null>(null)
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
const [error, setError] = useState<ChatError | null>(null)
const agentIdRef = useRef<string | null>(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<string, unknown>) => ({
...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<string, unknown>
}
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[] : []
}

View file

@ -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<ChatMode>('select')
const [defaultAgentId, setDefaultAgentId] = useState<string | null>(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 (
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
@ -53,7 +43,7 @@ export default function ChatPage() {
<div className="flex flex-col gap-3 w-full max-w-xs">
<Button
size="lg"
onClick={() => handleModeChange('local')}
onClick={() => setMode('local')}
disabled={!defaultAgentId}
className="w-full"
>
@ -64,7 +54,7 @@ export default function ChatPage() {
<Button
size="lg"
variant="outline"
onClick={() => handleModeChange('remote')}
onClick={() => setMode('remote')}
className="w-full"
>
Remote Agent
@ -81,159 +71,92 @@ export default function ChatPage() {
)
}
// Local chat mode - uses useLocalChat hook that bridges to useMessagesStore
if (mode === 'local' && defaultAgentId) {
return <LocalChatView agentId={defaultAgentId} onBack={() => handleModeChange('select')} />
if (mode === 'local') {
return <LocalChatView onBack={() => setMode('select')} />
}
// Remote chat mode - uses Gateway connection
return <RemoteChatView onBack={() => handleModeChange('select')} />
return <RemoteChatView onBack={() => setMode('select')} />
}
/**
* Local Chat View - Direct IPC communication with agent
* Uses useLocalChat hook which bridges IPC events to useMessagesStore
* Local Chat View - Direct IPC communication with agent.
* useLocalChat() fetches agentId internally and returns UseChatReturn shape.
*/
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<HTMLElement>(null)
const fadeStyle = useScrollFade(mainRef)
useAutoScroll(mainRef)
const handleDisconnect = useCallback(() => {
disconnect()
onBack()
}, [disconnect, onBack])
function LocalChatView({ onBack }: { onBack: () => void }) {
const chat = useLocalChat()
return (
<div className="h-full flex flex-col overflow-hidden w-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onBack}>
Back
</Button>
<span className="text-sm font-medium">Local Agent</span>
<span
className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleDisconnect}
className="text-xs text-muted-foreground"
>
Disconnect
</Button>
</div>
{/* Messages - same component as Gateway mode */}
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Send a message to start the conversation
</div>
) : (
<MessageList messages={messages} streamingIds={streamingIds} />
)}
</main>
{/* Input - same component as Gateway mode */}
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
<ChatInput
onSubmit={sendMessage}
disabled={!isConnected || isLoading}
placeholder={!isConnected ? 'Connecting...' : 'Type a message...'}
/>
</footer>
<ChatView {...chat} onDisconnect={onBack} />
</div>
)
}
/**
* 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<HTMLElement>(null)
const fadeStyle = useScrollFade(mainRef)
useAutoScroll(mainRef)
}
return (
<div className="h-full flex flex-col overflow-hidden w-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onBack}>
Back
</Button>
<span className="text-sm font-medium">Remote Agent</span>
{pageState === 'loading' && (
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
<Loading />
Loading...
</div>
{isConnected && (
<Button
variant="ghost"
size="sm"
onClick={handleDisconnect}
className="text-xs text-muted-foreground"
>
Disconnect
</Button>
)}
</div>
)}
{/* Messages */}
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
{loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading...
</div>
) : !isConnected ? (
<ConnectPrompt />
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Send a message to start the conversation
</div>
) : (
<MessageList messages={messages} streamingIds={streamingIds} />
)}
</main>
{/* Input */}
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
<ChatInput
onSubmit={handleSend}
disabled={!isConnected}
placeholder={!isConnected ? 'Connect first...' : 'Type a message...'}
{(pageState === 'not-connected' || pageState === 'connecting') && (
<DevicePairing
key={pairingKey}
connectionState={connectionState}
lastError={error}
onConnect={connect}
onCancel={handleDisconnect}
/>
</footer>
)}
{pageState === 'connected' && client && identity && (
<ConnectedRemoteChat
client={client}
hubId={identity.hubId}
agentId={identity.agentId}
onDisconnect={handleDisconnect}
/>
)}
</div>
)
}
/** Thin wrapper that wires useChat to the shared ChatView */
function ConnectedRemoteChat({
client,
hubId,
agentId,
onDisconnect,
}: {
client: NonNullable<ReturnType<typeof useGatewayConnection>['client']>
hubId: string
agentId: string
onDisconnect: () => void
}) {
const chat = useChat({ client, hubId, agentId })
return <ChatView {...chat} onDisconnect={onDisconnect} />
}

19
pnpm-lock.yaml generated
View file

@ -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