Merge remote-tracking branch 'origin/main' into copilothub-web-search
This commit is contained in:
commit
ec6dbff61c
68 changed files with 4245 additions and 1371 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 /> },
|
||||
],
|
||||
|
|
|
|||
54
apps/desktop/src/components/local-chat.tsx
Normal file
54
apps/desktop/src/components/local-chat.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
error,
|
||||
pendingApprovals,
|
||||
sendMessage,
|
||||
loadMore,
|
||||
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}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
error={error}
|
||||
pendingApprovals={pendingApprovals}
|
||||
sendMessage={sendMessage}
|
||||
loadMore={loadMore}
|
||||
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,166 +1,151 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useMessagesStore } from '@multica/store'
|
||||
import type { ContentBlock, CompactionEndEvent } from '@multica/sdk'
|
||||
import { useChat } from '@multica/hooks/use-chat'
|
||||
import type {
|
||||
StreamPayload,
|
||||
ExecApprovalRequestPayload,
|
||||
ApprovalDecision,
|
||||
AgentMessageItem,
|
||||
} from '@multica/sdk'
|
||||
import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk'
|
||||
|
||||
interface UseLocalChatOptions {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
interface UseLocalChatReturn {
|
||||
isConnected: boolean
|
||||
isLoading: boolean
|
||||
sendMessage: (content: string) => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides local IPC chat that uses the same useMessagesStore as Gateway mode.
|
||||
* This enables full Chat component reuse.
|
||||
*/
|
||||
export function useLocalChat({ agentId }: UseLocalChatOptions): UseLocalChatReturn {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
export function useLocalChat() {
|
||||
const chat = useChat()
|
||||
const chatRef = useRef(chat)
|
||||
chatRef.current = chat
|
||||
const [agentId, setAgentId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const currentStreamRef = useRef<string | null>(null)
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const isLoadingMoreRef = useRef(false)
|
||||
const [initError, setInitError] = useState<string | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const offsetRef = useRef<number | null>(null)
|
||||
|
||||
// Subscribe to agent events on mount
|
||||
// Initialize hub and get default agent ID
|
||||
useEffect(() => {
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setInitError(err.message)
|
||||
setIsLoadingHistory(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Subscribe to events + fetch history once agentId is available
|
||||
useEffect(() => {
|
||||
if (!agentId) return
|
||||
|
||||
const subscribe = async () => {
|
||||
const result = await window.electronAPI.localChat.subscribe(agentId)
|
||||
if (result.ok) {
|
||||
setIsConnected(true)
|
||||
}
|
||||
}
|
||||
// Subscribe to agent events
|
||||
window.electronAPI.localChat.subscribe(agentId).catch(() => {})
|
||||
|
||||
subscribe()
|
||||
// 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
|
||||
|
||||
// Load message history from agent session
|
||||
const loadHistory = async () => {
|
||||
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[]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// History load is best-effort
|
||||
}
|
||||
}
|
||||
loadHistory()
|
||||
|
||||
// Listen for events and route to useMessagesStore
|
||||
window.electronAPI.localChat.onEvent((event) => {
|
||||
if (event.agentId !== agentId) return
|
||||
|
||||
const store = useMessagesStore.getState()
|
||||
|
||||
// Handle error
|
||||
if (event.type === 'error') {
|
||||
store.addAssistantMessage(event.content ?? 'Unknown error', agentId)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle agent events - same logic as connection-store.ts
|
||||
const agentEvent = event.event
|
||||
const streamId = event.streamId
|
||||
if (!agentEvent) return
|
||||
|
||||
// Handle compaction events (no streamId required)
|
||||
if (agentEvent.type === 'compaction_start') {
|
||||
store.startCompaction()
|
||||
return
|
||||
}
|
||||
if (agentEvent.type === 'compaction_end') {
|
||||
const evt = agentEvent as CompactionEndEvent
|
||||
store.endCompaction({
|
||||
removed: evt.removed,
|
||||
kept: evt.kept,
|
||||
tokensRemoved: evt.tokensRemoved,
|
||||
tokensKept: evt.tokensKept,
|
||||
reason: evt.reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!streamId) 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
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
chatRef.current.handleStream(payload)
|
||||
if (payload.event.type === 'message_start') setIsLoading(true)
|
||||
if (payload.event.type === 'message_end') setIsLoading(false)
|
||||
})
|
||||
|
||||
// Listen for exec approval requests
|
||||
window.electronAPI.localChat.onApproval((approval) => {
|
||||
chatRef.current.addApproval(approval as ExecApprovalRequestPayload)
|
||||
})
|
||||
|
||||
// Fetch history with pagination
|
||||
window.electronAPI.localChat.getHistory(agentId, { limit: DEFAULT_MESSAGES_LIMIT })
|
||||
.then((result) => {
|
||||
console.log('[LocalChat] getHistory result:', result.messages?.length, 'messages, total:', result.total)
|
||||
if (result.messages?.length) {
|
||||
chatRef.current.setHistory(result.messages as AgentMessageItem[], agentId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
})
|
||||
offsetRef.current = result.offset
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoadingHistory(false))
|
||||
|
||||
return () => {
|
||||
window.electronAPI.localChat.offEvent()
|
||||
window.electronAPI.localChat.unsubscribe(agentId)
|
||||
setIsConnected(false)
|
||||
window.electronAPI.localChat.offApproval()
|
||||
window.electronAPI.localChat.unsubscribe(agentId).catch(() => {})
|
||||
}
|
||||
}, [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)
|
||||
(text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || !agentId) return
|
||||
chatRef.current.addUserMessage(trimmed, agentId)
|
||||
chatRef.current.setError(null)
|
||||
window.electronAPI.localChat.send(agentId, trimmed).catch(() => {})
|
||||
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]
|
||||
[agentId],
|
||||
)
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
useMessagesStore.getState().clearMessages()
|
||||
setIsConnected(false)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
const loadMore = useCallback(async () => {
|
||||
const currentOffset = offsetRef.current
|
||||
if (!agentId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return
|
||||
|
||||
isLoadingMoreRef.current = true
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT)
|
||||
const limit = currentOffset - newOffset
|
||||
const result = await window.electronAPI.localChat.getHistory(agentId, { offset: newOffset, limit })
|
||||
if (result.messages?.length) {
|
||||
chatRef.current.prependHistory(result.messages as AgentMessageItem[], agentId, {
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
})
|
||||
offsetRef.current = result.offset
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — pagination failure does not block chat
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const resolveApproval = useCallback(
|
||||
(approvalId: string, decision: ApprovalDecision) => {
|
||||
chatRef.current.removeApproval(approvalId)
|
||||
window.electronAPI.localChat.resolveExecApproval(approvalId, decision).catch(() => {})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
agentId,
|
||||
initError,
|
||||
messages: chat.messages,
|
||||
streamingIds: chat.streamingIds,
|
||||
isLoading,
|
||||
isLoadingHistory,
|
||||
isLoadingMore,
|
||||
hasMore: chat.hasMore,
|
||||
error: chat.error,
|
||||
pendingApprovals: chat.pendingApprovals,
|
||||
sendMessage,
|
||||
disconnect,
|
||||
loadMore,
|
||||
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,239 +1,128 @@
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } 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 { 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()
|
||||
|
||||
export default function ChatPage() {
|
||||
const [mode, setMode] = useState<ChatMode>('select')
|
||||
const [defaultAgentId, setDefaultAgentId] = useState<string | null>(null)
|
||||
|
||||
// Get default agent ID on mount
|
||||
useEffect(() => {
|
||||
const loadAgentId = async () => {
|
||||
const status = await window.electronAPI.hub.getStatus()
|
||||
if (status.defaultAgent?.agentId) {
|
||||
setDefaultAgentId(status.defaultAgent.agentId)
|
||||
}
|
||||
}
|
||||
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">
|
||||
<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={() => handleModeChange('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={() => handleModeChange('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>
|
||||
)
|
||||
}
|
||||
|
||||
// Local chat mode - uses useLocalChat hook that bridges to useMessagesStore
|
||||
if (mode === 'local' && defaultAgentId) {
|
||||
return <LocalChatView agentId={defaultAgentId} onBack={() => handleModeChange('select')} />
|
||||
}
|
||||
|
||||
// Remote chat mode - uses Gateway connection
|
||||
return <RemoteChatView onBack={() => handleModeChange('select')} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Local Chat View - Direct IPC communication with agent
|
||||
* Uses useLocalChat hook which bridges IPC events to useMessagesStore
|
||||
*/
|
||||
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])
|
||||
if (mode === 'select') return null
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote Chat View - Gateway connection to external Hub
|
||||
* Same as the original Chat component
|
||||
*/
|
||||
function RemoteChatView({ onBack }: { onBack: () => void }) {
|
||||
const { loading } = useAutoConnect()
|
||||
|
||||
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()
|
||||
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>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="text-xs text-muted-foreground"
|
||||
{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>
|
||||
|
||||
{/* 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...'}
|
||||
/>
|
||||
</footer>
|
||||
</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 = useChatModeStore((s) => s.mode)
|
||||
const gateway = useGatewayConnection()
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatPanel({
|
||||
visible,
|
||||
children,
|
||||
}: {
|
||||
visible: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
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