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:
parent
b896ac402a
commit
22bd392cb0
4 changed files with 319 additions and 261 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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[] : []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
19
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue