feat(desktop): implement local chat with direct IPC and mode switching
Add LocalChat component using useLocalChat hook that communicates with the Hub via IPC (no Gateway required). Fix streamId extraction to use event.message.id matching Hub behavior. Fix history to return raw AgentMessageItem[] instead of flattened strings. Add exec approval forwarding over IPC. Use conditional rendering for LocalChat to prevent event leaking from remote sessions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a21686505
commit
607adeb667
12 changed files with 445 additions and 437 deletions
|
|
@ -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: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'chat', element: <ChatPage /> },
|
||||
{ path: 'chat' },
|
||||
{ path: 'tools', element: <ToolsPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
],
|
||||
|
|
|
|||
48
apps/desktop/src/components/local-chat.tsx
Normal file
48
apps/desktop/src/components/local-chat.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-destructive">
|
||||
{initError}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Initializing...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatView
|
||||
messages={messages}
|
||||
streamingIds={streamingIds}
|
||||
isLoading={isLoading}
|
||||
isLoadingHistory={isLoadingHistory}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
resolveApproval={resolveApproval}
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
apps/desktop/src/components/remote-chat.tsx
Normal file
51
apps/desktop/src/components/remote-chat.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{pageState === 'loading' && (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pageState === 'not-connected' || pageState === 'connecting') && (
|
||||
<DevicePairing
|
||||
key={pairingKey}
|
||||
connectionState={connectionState}
|
||||
lastError={error}
|
||||
onConnect={connect}
|
||||
onCancel={disconnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pageState === 'connected' && client && identity && (
|
||||
<ConnectedChat
|
||||
client={client}
|
||||
hubId={identity.hubId}
|
||||
agentId={identity.agentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedChat({
|
||||
client,
|
||||
hubId,
|
||||
agentId,
|
||||
}: {
|
||||
client: NonNullable<UseGatewayConnectionReturn['client']>
|
||||
hubId: string
|
||||
agentId: string
|
||||
}) {
|
||||
const chat = useGatewayChat({ client, hubId, agentId })
|
||||
return <ChatView {...chat} />
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <ChatView> 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<Message[]>([])
|
||||
const [streamingIds, setStreamingIds] = useState<Set<string>>(new Set())
|
||||
export function useLocalChat() {
|
||||
const chat = useChat()
|
||||
const [agentId, setAgentId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
|
||||
const [error, setError] = useState<ChatError | null>(null)
|
||||
|
||||
const agentIdRef = useRef<string | null>(null)
|
||||
const [initError, setInitError] = useState<string | null>(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<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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-1 px-6 py-1 shrink-0">
|
||||
<NavButton active={mode === 'local'} onClick={() => setMode('local')}>
|
||||
Local
|
||||
</NavButton>
|
||||
<NavButton active={mode === 'remote'} onClick={() => setMode('remote')}>
|
||||
Remote
|
||||
</NavButton>
|
||||
|
||||
{mode === 'remote' && gateway.pageState === 'connected' && (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={gateway.disconnect}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeSelect() {
|
||||
const setMode = useChatModeStore((s) => s.setMode)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-lg font-semibold">Start a Conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how you want to connect
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setMode('local')}
|
||||
className="w-full"
|
||||
>
|
||||
Local Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setMode('remote')}
|
||||
className="w-full"
|
||||
>
|
||||
Remote Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [mode, setMode] = useState<ChatMode>('select')
|
||||
const [defaultAgentId, setDefaultAgentId] = useState<string | null>(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 (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-6 p-4">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-lg font-semibold">Start a Conversation</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how you want to connect
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setMode('local')}
|
||||
disabled={!defaultAgentId}
|
||||
className="w-full"
|
||||
>
|
||||
Local Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Direct IPC)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setMode('remote')}
|
||||
className="w-full"
|
||||
>
|
||||
Remote Agent
|
||||
<span className="text-xs ml-2 opacity-70">(Via Gateway)</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!defaultAgentId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Waiting for local agent to initialize...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'local') {
|
||||
return <LocalChatView onBack={() => setMode('select')} />
|
||||
}
|
||||
|
||||
return <RemoteChatView onBack={() => 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 (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
<ChatView {...chat} onDisconnect={onBack} />
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<ModeNav gateway={gateway} />
|
||||
|
||||
{mode === 'select' && <ModeSelect />}
|
||||
|
||||
{mode === 'local' && <LocalChat />}
|
||||
|
||||
<ChatPanel visible={mode === 'remote'}>
|
||||
<RemoteChat gateway={gateway} />
|
||||
</ChatPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full">
|
||||
{pageState === 'loading' && (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loading />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pageState === 'not-connected' || pageState === 'connecting') && (
|
||||
<DevicePairing
|
||||
key={pairingKey}
|
||||
connectionState={connectionState}
|
||||
lastError={error}
|
||||
onConnect={connect}
|
||||
onCancel={handleDisconnect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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,
|
||||
function ChatPanel({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
client: NonNullable<ReturnType<typeof useGatewayConnection>['client']>
|
||||
hubId: string
|
||||
agentId: string
|
||||
onDisconnect: () => void
|
||||
visible: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const chat = useChat({ client, hubId, agentId })
|
||||
|
||||
return <ChatView {...chat} onDisconnect={onDisconnect} />
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${visible ? 'flex flex-col' : 'hidden'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<main className={cn('flex-1 overflow-auto', location.pathname === '/chat' ? '' : 'p-4')}>
|
||||
<Outlet />
|
||||
<main className="flex-1 overflow-auto relative">
|
||||
{/* ChatPage is always mounted (cached), hidden via CSS */}
|
||||
<div className={cn('absolute inset-0', location.pathname === '/chat' ? '' : 'hidden')}>
|
||||
<ChatPage />
|
||||
</div>
|
||||
|
||||
{/* Other routes render normally via Outlet */}
|
||||
{location.pathname !== '/chat' && (
|
||||
<div className="p-4 h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Toaster />
|
||||
<DeviceConfirmDialog />
|
||||
|
|
|
|||
13
apps/desktop/src/stores/chat-mode.ts
Normal file
13
apps/desktop/src/stores/chat-mode.ts
Normal file
|
|
@ -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<ChatModeStore>((set) => ({
|
||||
mode: "select",
|
||||
setMode: (mode) => set({ mode }),
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue