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:
Naiyuan Qing 2026-02-05 17:50:55 +08:00
parent 7a21686505
commit 607adeb667
12 changed files with 445 additions and 437 deletions

View file

@ -101,6 +101,16 @@ interface LocalChatEvent {
}
}
interface LocalChatApproval {
approvalId: string
agentId: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
}
interface ProviderStatus {
id: string
name: string
@ -140,6 +150,10 @@ interface ElectronAPI {
deviceConfirmResponse: (deviceId: string, allowed: boolean) => void
listDevices: () => Promise<DeviceEntryInfo[]>
revokeDevice: (deviceId: string) => Promise<{ ok: boolean }>
onConnectionStateChanged: (callback: (state: string) => void) => void
offConnectionStateChanged: () => void
onDevicesChanged: (callback: () => void) => void
offDevicesChanged: () => void
}
tools: {
list: () => Promise<ToolInfo[]>
@ -179,10 +193,13 @@ interface ElectronAPI {
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>
getHistory: (agentId: string) => Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; agentId: string }> }>
getHistory: (agentId: string) => Promise<{ messages: unknown[] }>
send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }>
resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }>
onEvent: (callback: (event: LocalChatEvent) => void) => void
offEvent: () => void
onApproval: (callback: (approval: LocalChatApproval) => void) => void
offApproval: () => void
}
}

View file

@ -9,18 +9,6 @@ import { Hub } from '../../../../src/hub/hub.js'
import type { ConnectionState } from '@multica/sdk'
import type { AsyncAgent } from '../../../../src/agent/async-agent.js'
/**
* Extract plain text from AgentMessage content (string or content block array).
*/
function extractTextContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.filter((c: { type?: string }) => c.type === 'text')
.map((c: { text?: string }) => c.text ?? '')
.join('')
}
// Singleton Hub instance
let hub: Hub | null = null
let defaultAgentId: string | null = null
@ -290,9 +278,10 @@ export function registerHubIpcHandlers(): void {
if (!shouldForward) return
// Track stream ID for message grouping
// Track stream ID for message grouping (extract from event.message.id, same as Hub.beginStream)
if (event.type === 'message_start') {
currentStreamId = (event as { id?: string }).id ?? `stream-${Date.now()}`
const msgId = (event as { message?: { id?: string } }).message?.id
currentStreamId = msgId ?? `stream-${Date.now()}`
safeLog(`[IPC] Starting stream: ${currentStreamId}`)
}
@ -310,6 +299,14 @@ export function registerHubIpcHandlers(): void {
})
ipcAgentSubscriptions.set(agentId, unsubscribe)
// Register local approval handler so exec approval requests route via IPC
h.setLocalApprovalHandler(agentId, (payload) => {
if (!mainWindowRef || mainWindowRef.isDestroyed()) return
safeLog(`[IPC] Sending approval request to renderer: ${payload.approvalId}`)
mainWindowRef.webContents.send('localChat:approval', payload)
})
safeLog(`[IPC] Local chat subscribed to agent: ${agentId}`)
return { ok: true }
@ -324,13 +321,15 @@ export function registerHubIpcHandlers(): void {
unsubscribe()
}
ipcAgentSubscriptions.delete(agentId)
getHub().removeLocalApprovalHandler(agentId)
safeLog(`[IPC] Local chat unsubscribed from agent: ${agentId}`)
return { ok: true }
})
/**
* Get message history for local chat.
* Returns messages in the same format as useMessagesStore.
* Returns raw AgentMessageItem[] so the renderer can render content blocks,
* tool results, thinking blocks, etc. same format as the Gateway RPC.
*/
ipcMain.handle('localChat:getHistory', async (_event, agentId: string) => {
const h = getHub()
@ -340,17 +339,8 @@ export function registerHubIpcHandlers(): void {
}
try {
const sessionMessages = agent.getMessages()
const messages = sessionMessages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m, i) => ({
id: `history-${i}-${Date.now()}`,
role: m.role as 'user' | 'assistant',
content: extractTextContent((m as { content?: unknown }).content),
agentId,
}))
.filter((m) => m.content.length > 0)
await agent.ensureInitialized()
const messages = agent.getMessages()
return { messages }
} catch {
return { messages: [] }
@ -381,6 +371,15 @@ export function registerHubIpcHandlers(): void {
return { ok: true }
})
/**
* Resolve an exec approval request for local chat.
*/
ipcMain.handle('localChat:resolveExecApproval', async (_event, approvalId: string, decision: string) => {
const h = getHub()
const ok = h.resolveExecApproval(approvalId, decision as 'allow-once' | 'allow-always' | 'deny')
return { ok }
})
/**
* Register a one-time token for device verification.
* Called by the QR code component when a token is generated or refreshed.
@ -404,7 +403,12 @@ export function registerHubIpcHandlers(): void {
*/
ipcMain.handle('hub:revokeDevice', async (_event, deviceId: string) => {
const h = getHub()
return { ok: h.deviceStore.revokeDevice(deviceId) }
const ok = h.deviceStore.revokeDevice(deviceId)
// Notify renderer that device list changed
if (ok && mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send('hub:devices-changed')
}
return { ok }
})
}
@ -440,10 +444,21 @@ export function setupDeviceConfirmation(mainWindow: Electron.BrowserWindow): voi
pendingConfirms.set(deviceId, (allowed: boolean) => {
clearTimeout(timeout)
resolve(allowed)
// Notify renderer that device list changed when a device is approved
if (allowed && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('hub:devices-changed')
}
})
mainWindow.webContents.send('hub:device-confirm-request', deviceId, meta)
})
})
// Forward connection state changes to renderer
h.onConnectionStateChange((state) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('hub:connection-state-changed', state)
}
})
}
/**

View file

@ -82,6 +82,17 @@ export interface LocalChatEvent {
}
}
// Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk)
export interface LocalChatApproval {
approvalId: string
agentId: string
command: string
cwd?: string
riskLevel: 'safe' | 'needs-review' | 'dangerous'
riskReasons: string[]
expiresAtMs: number
}
// Available style options
export const AGENT_STYLES = ['concise', 'warm', 'playful', 'professional'] as const
export type AgentStyle = (typeof AGENT_STYLES)[number]
@ -117,6 +128,18 @@ const electronAPI = {
},
listDevices: () => ipcRenderer.invoke('hub:listDevices'),
revokeDevice: (deviceId: string) => ipcRenderer.invoke('hub:revokeDevice', deviceId),
onConnectionStateChanged: (callback: (state: string) => void) => {
ipcRenderer.on('hub:connection-state-changed', (_event, state: string) => callback(state))
},
offConnectionStateChanged: () => {
ipcRenderer.removeAllListeners('hub:connection-state-changed')
},
onDevicesChanged: (callback: () => void) => {
ipcRenderer.on('hub:devices-changed', () => callback())
},
offDevicesChanged: () => {
ipcRenderer.removeAllListeners('hub:devices-changed')
},
},
// Tools management
@ -184,11 +207,13 @@ const electronAPI = {
subscribe: (agentId: string) => ipcRenderer.invoke('localChat:subscribe', agentId),
/** Unsubscribe from agent events */
unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId),
/** Get message history for local chat */
getHistory: (agentId: string): Promise<{ messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; agentId: string }> }> =>
ipcRenderer.invoke('localChat:getHistory', agentId),
/** Get message history for local chat (returns raw AgentMessageItem[]) */
getHistory: (agentId: string) => ipcRenderer.invoke('localChat:getHistory', agentId),
/** Send message to agent via direct IPC (no Gateway) */
send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content),
/** Resolve an exec approval request */
resolveExecApproval: (approvalId: string, decision: string) =>
ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision),
/** Listen for agent events */
onEvent: (callback: (event: LocalChatEvent) => void) => {
ipcRenderer.on('localChat:event', (_event, data: LocalChatEvent) => callback(data))
@ -197,6 +222,14 @@ const electronAPI = {
offEvent: () => {
ipcRenderer.removeAllListeners('localChat:event')
},
/** Listen for exec approval requests */
onApproval: (callback: (approval: LocalChatApproval) => void) => {
ipcRenderer.on('localChat:approval', (_event, data: LocalChatApproval) => callback(data))
},
/** Remove approval listener */
offApproval: () => {
ipcRenderer.removeAllListeners('localChat:approval')
},
},
}

View file

@ -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 /> },
],

View 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}
/>
)
}

View 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} />
}

View file

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

View file

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

View file

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

View file

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

View file

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

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