diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts index 0d52ba52..d4a1e87c 100644 --- a/apps/desktop/src/main/electron-env.d.ts +++ b/apps/desktop/src/main/electron-env.d.ts @@ -96,9 +96,10 @@ interface ProfileData { userContent: string | undefined } -interface LocalChatEvent { - agentId: string - streamId?: string +interface LocalChatEvent { + agentId: string + conversationId?: string + streamId?: string type?: 'error' content?: string event?: { @@ -112,10 +113,11 @@ interface LocalChatEvent { } } -interface LocalChatApproval { - approvalId: string - agentId: string - command: string +interface LocalChatApproval { + approvalId: string + agentId: string + conversationId?: string + command: string cwd?: string riskLevel: 'safe' | 'needs-review' | 'dangerous' riskReasons: string[] @@ -155,12 +157,13 @@ type MessageSource = | { type: 'gateway'; deviceId: string } | { type: 'channel'; channelId: string; accountId: string; conversationId: string } -interface InboundMessageEvent { - agentId: string - content: string - source: MessageSource - timestamp: number -} +interface InboundMessageEvent { + agentId: string + conversationId?: string + content: string + source: MessageSource + timestamp: number +} interface ElectronAPI { app: { @@ -174,13 +177,17 @@ interface ElectronAPI { init: () => Promise getStatus: () => Promise getAgentInfo: () => Promise - info: () => Promise - reconnect: (url: string) => Promise - listAgents: () => Promise - createAgent: (id?: string) => Promise - getAgent: (id: string) => Promise - closeAgent: (id: string) => Promise - sendMessage: (agentId: string, content: string) => Promise + info: () => Promise + reconnect: (url: string) => Promise + listAgents: () => Promise + listConversations: () => Promise + createAgent: (id?: string) => Promise + createConversation: (id?: string) => Promise + getAgent: (id: string) => Promise + getConversation: (id: string) => Promise + closeAgent: (id: string) => Promise + closeConversation: (id: string) => Promise + sendMessage: (agentId: string, content: string, conversationId?: string) => Promise registerToken: (token: string, agentId: string, expiresAt: number) => Promise onDeviceConfirmRequest: (callback: (deviceId: string, meta?: DeviceMeta) => void) => void offDeviceConfirmRequest: () => void @@ -250,9 +257,9 @@ interface ElectronAPI { localChat: { subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> unsubscribe: (agentId: string) => Promise<{ ok: boolean }> - getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }> - send: (agentId: string, content: string) => Promise<{ ok?: boolean; error?: string }> - abort: (agentId: string) => Promise<{ ok?: boolean; error?: string }> + getHistory: (agentId: string, options?: { offset?: number; limit?: number; conversationId?: string }) => Promise<{ messages: unknown[]; total: number; offset: number; limit: number; contextWindowTokens?: number }> + send: (agentId: string, content: string, conversationId?: string) => Promise<{ ok?: boolean; error?: string }> + abort: (agentId: string, conversationId?: string) => Promise<{ ok?: boolean; error?: string }> resolveExecApproval: (approvalId: string, decision: string) => Promise<{ ok: boolean }> onEvent: (callback: (event: LocalChatEvent) => void) => void offEvent: () => void diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index 93c4a505..0af0955d 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -112,6 +112,7 @@ export function registerHubIpcHandlers(): void { url: h.url, connectionState: h.connectionState, defaultAgentId, + defaultConversationId: defaultAgentId, } }) @@ -188,6 +189,21 @@ export function registerHubIpcHandlers(): void { }) }) + /** + * List all conversations (alias of listAgents for clearer semantics). + */ + ipcMain.handle('hub:listConversations', async (): Promise => { + const h = getHub() + const conversationIds = h.listConversations() + return conversationIds.map((id) => { + const conversation = h.getConversation(id) + return { + id, + closed: conversation?.closed ?? true, + } + }) + }) + /** * Create a new agent. */ @@ -200,6 +216,18 @@ export function registerHubIpcHandlers(): void { } }) + /** + * Create a new conversation (alias of createAgent). + */ + ipcMain.handle('hub:createConversation', async (_event, id?: string) => { + const h = getHub() + const conversation = h.createConversation(id) + return { + id: conversation.sessionId, + closed: conversation.closed, + } + }) + /** * Get a specific agent. */ @@ -215,6 +243,21 @@ export function registerHubIpcHandlers(): void { } }) + /** + * Get a specific conversation (alias of getAgent). + */ + ipcMain.handle('hub:getConversation', async (_event, id: string) => { + const h = getHub() + const conversation = h.getConversation(id) + if (!conversation) { + return { error: `Conversation not found: ${id}` } + } + return { + id: conversation.sessionId, + closed: conversation.closed, + } + }) + /** * Close/delete an agent. */ @@ -224,18 +267,28 @@ export function registerHubIpcHandlers(): void { return { ok: result } }) + /** + * Close/delete a conversation (alias of closeAgent). + */ + ipcMain.handle('hub:closeConversation', async (_event, id: string) => { + const h = getHub() + const result = h.closeConversation(id) + return { ok: result } + }) + /** * Send a message to an agent (for remote clients via Gateway). * Note: For local direct chat, use 'localChat:send' instead. */ - ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string) => { + ipcMain.handle('hub:sendMessage', async (_event, agentId: string, content: string, conversationId?: string) => { const h = getHub() - const agent = h.getAgent(agentId) + const resolvedConversationId = conversationId ?? agentId + const agent = h.getAgent(resolvedConversationId) if (!agent) { - return { error: `Agent not found: ${agentId}` } + return { error: `Conversation not found: ${resolvedConversationId}` } } if (agent.closed) { - return { error: `Agent is closed: ${agentId}` } + return { error: `Conversation is closed: ${resolvedConversationId}` } } h.channelManager.clearLastRoute() agent.write(content) @@ -277,6 +330,7 @@ export function registerHubIpcHandlers(): void { safeLog(`[IPC] Sending ${event.type} event to renderer`) mainWindowRef.webContents.send('localChat:event', { agentId, + conversationId: agentId, streamId: null, event, }) @@ -305,6 +359,7 @@ export function registerHubIpcHandlers(): void { safeLog(`[IPC] Sending event to renderer: ${event.type}, streamId: ${currentStreamId}`) mainWindowRef.webContents.send('localChat:event', { agentId, + conversationId: agentId, streamId: currentStreamId, event, }) @@ -351,9 +406,14 @@ export function registerHubIpcHandlers(): void { * Reads from session storage (not in-memory state) so that internal * orchestration messages are excluded by default. */ - ipcMain.handle('localChat:getHistory', async (_event, agentId: string, options?: { offset?: number; limit?: number }) => { + ipcMain.handle('localChat:getHistory', async ( + _event, + agentId: string, + options?: { offset?: number; limit?: number; conversationId?: string }, + ) => { const h = getHub() - const agent = h.getAgent(agentId) + const conversationId = options?.conversationId ?? agentId + const agent = h.getAgent(conversationId) if (!agent) { return { messages: [], total: 0, offset: 0, limit: 0, contextWindowTokens: undefined } } @@ -377,18 +437,19 @@ export function registerHubIpcHandlers(): void { * Send a message via local direct IPC (no Gateway). * Events will be pushed to renderer via 'localChat:event' channel. */ - ipcMain.handle('localChat:send', async (_event, agentId: string, content: string) => { + ipcMain.handle('localChat:send', async (_event, agentId: string, content: string, conversationId?: string) => { const h = getHub() - const agent = h.getAgent(agentId) + const resolvedConversationId = conversationId ?? agentId + const agent = h.getAgent(resolvedConversationId) if (!agent) { - return { error: `Agent not found: ${agentId}` } + return { error: `Conversation not found: ${resolvedConversationId}` } } if (agent.closed) { - return { error: `Agent is closed: ${agentId}` } + return { error: `Conversation is closed: ${resolvedConversationId}` } } // Must be subscribed first to receive events - if (!ipcAgentSubscriptions.has(agentId)) { + if (!ipcAgentSubscriptions.has(resolvedConversationId)) { return { error: 'Not subscribed to agent events. Call subscribe first.' } } @@ -397,26 +458,28 @@ export function registerHubIpcHandlers(): void { // Broadcast as local source (for consistency, though UI already knows) h.broadcastInbound({ agentId, + conversationId: resolvedConversationId, content, source, timestamp: Date.now(), }) agent.write(content, { source }) - safeLog(`[IPC] Local chat message sent to agent: ${agentId}`) + safeLog(`[IPC] Local chat message sent to conversation: ${resolvedConversationId}`) return { ok: true } }) /** * Abort the current agent run for local chat. */ - ipcMain.handle('localChat:abort', async (_event, agentId: string) => { + ipcMain.handle('localChat:abort', async (_event, agentId: string, conversationId?: string) => { const h = getHub() - const agent = h.getAgent(agentId) + const resolvedConversationId = conversationId ?? agentId + const agent = h.getAgent(resolvedConversationId) if (!agent) { - return { error: `Agent not found: ${agentId}` } + return { error: `Conversation not found: ${resolvedConversationId}` } } agent.abort() - safeLog(`[IPC] Abort sent to agent: ${agentId}`) + safeLog(`[IPC] Abort sent to conversation: ${resolvedConversationId}`) return { ok: true } }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 07e6067a..2bf8ec6b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -65,9 +65,10 @@ export interface CurrentProviderInfo { } // Local chat event types (for direct IPC communication without Gateway) -export interface LocalChatEvent { - agentId: string - streamId?: string +export interface LocalChatEvent { + agentId: string + conversationId?: string + streamId?: string type?: 'error' content?: string event?: { @@ -87,18 +88,20 @@ export type MessageSource = | { type: 'gateway'; deviceId: string } | { type: 'channel'; channelId: string; accountId: string; conversationId: string } -export interface InboundMessageEvent { - agentId: string - content: string - source: MessageSource - timestamp: number -} +export interface InboundMessageEvent { + agentId: string + conversationId?: string + content: string + source: MessageSource + timestamp: number +} // Local chat approval request (mirrors ExecApprovalRequestPayload from @multica/sdk) -export interface LocalChatApproval { - approvalId: string - agentId: string - command: string +export interface LocalChatApproval { + approvalId: string + agentId: string + conversationId?: string + command: string cwd?: string riskLevel: 'safe' | 'needs-review' | 'dangerous' riskReasons: string[] @@ -156,13 +159,17 @@ const electronAPI = { getStatus: (): Promise => ipcRenderer.invoke('hub:getStatus'), getAgentInfo: (): Promise => ipcRenderer.invoke('hub:getAgentInfo'), info: () => ipcRenderer.invoke('hub:info'), - reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url), - listAgents: () => ipcRenderer.invoke('hub:listAgents'), - createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id), - getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id), - closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id), - sendMessage: (agentId: string, content: string) => - ipcRenderer.invoke('hub:sendMessage', agentId, content), + reconnect: (url: string) => ipcRenderer.invoke('hub:reconnect', url), + listAgents: () => ipcRenderer.invoke('hub:listAgents'), + listConversations: () => ipcRenderer.invoke('hub:listConversations'), + createAgent: (id?: string) => ipcRenderer.invoke('hub:createAgent', id), + createConversation: (id?: string) => ipcRenderer.invoke('hub:createConversation', id), + getAgent: (id: string) => ipcRenderer.invoke('hub:getAgent', id), + getConversation: (id: string) => ipcRenderer.invoke('hub:getConversation', id), + closeAgent: (id: string) => ipcRenderer.invoke('hub:closeAgent', id), + closeConversation: (id: string) => ipcRenderer.invoke('hub:closeConversation', id), + sendMessage: (agentId: string, content: string, conversationId?: string) => + ipcRenderer.invoke('hub:sendMessage', agentId, content, conversationId), registerToken: (token: string, agentId: string, expiresAt: number) => ipcRenderer.invoke('hub:registerToken', token, agentId, expiresAt), onDeviceConfirmRequest: (callback: (deviceId: string, meta?: { userAgent?: string; platform?: string; language?: string }) => void) => { @@ -317,12 +324,14 @@ const electronAPI = { /** Unsubscribe from agent events */ unsubscribe: (agentId: string) => ipcRenderer.invoke('localChat:unsubscribe', agentId), /** Get message history for local chat with pagination (returns raw AgentMessageItem[]) */ - getHistory: (agentId: string, options?: { offset?: number; limit?: number }) => - ipcRenderer.invoke('localChat:getHistory', agentId, options), - /** Send message to agent via direct IPC (no Gateway) */ - send: (agentId: string, content: string) => ipcRenderer.invoke('localChat:send', agentId, content), - /** Abort the current agent run */ - abort: (agentId: string) => ipcRenderer.invoke('localChat:abort', agentId), + getHistory: (agentId: string, options?: { offset?: number; limit?: number; conversationId?: string }) => + ipcRenderer.invoke('localChat:getHistory', agentId, options), + /** Send message to agent via direct IPC (no Gateway) */ + send: (agentId: string, content: string, conversationId?: string) => + ipcRenderer.invoke('localChat:send', agentId, content, conversationId), + /** Abort the current agent run */ + abort: (agentId: string, conversationId?: string) => + ipcRenderer.invoke('localChat:abort', agentId, conversationId), /** Resolve an exec approval request */ resolveExecApproval: (approvalId: string, decision: string) => ipcRenderer.invoke('localChat:resolveExecApproval', approvalId, decision), diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index 469f0e49..1b65f2e8 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -10,12 +10,14 @@ import { QueuedMessageBar } from './queued-message-bar' interface LocalChatProps { initialPrompt?: string + conversationId?: string } -export function LocalChat({ initialPrompt }: LocalChatProps) { +export function LocalChat({ initialPrompt, conversationId }: LocalChatProps) { const navigate = useNavigate() const { agentId, + conversationId: activeConversationId, initError, messages, streamingIds, @@ -34,7 +36,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { loadMore, resolveApproval, clearError, - } = useLocalChat() + } = useLocalChat({ conversationId }) const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProviderStore() @@ -71,18 +73,21 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { // Auto-send initial prompt after a short delay const lastPromptRef = useRef(undefined) useEffect(() => { - if (!agentId || !initialPrompt) return + if (!activeConversationId || !initialPrompt) return if (initialPrompt === lastPromptRef.current) return const timer = setTimeout(() => { lastPromptRef.current = initialPrompt sendMessage(initialPrompt) // Remove prompt from URL to prevent re-sending on back navigation - navigate('/chat', { replace: true }) + const nextPath = activeConversationId + ? `/chat?conversation=${encodeURIComponent(activeConversationId)}` + : '/chat' + navigate(nextPath, { replace: true }) }, 500) return () => clearTimeout(timer) - }, [agentId, initialPrompt, sendMessage, navigate]) + }, [activeConversationId, initialPrompt, sendMessage, navigate]) if (initError) { return ( @@ -92,7 +97,7 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { ) } - if (!agentId) { + if (!activeConversationId) { return (
diff --git a/apps/desktop/src/renderer/src/components/qr-code.tsx b/apps/desktop/src/renderer/src/components/qr-code.tsx index 413f37d6..5a586952 100644 --- a/apps/desktop/src/renderer/src/components/qr-code.tsx +++ b/apps/desktop/src/renderer/src/components/qr-code.tsx @@ -11,6 +11,7 @@ export interface QRCodeData { gateway: string hubId: string agentId: string + conversationId?: string token: string expires: number } @@ -19,6 +20,7 @@ export interface ConnectionQRCodeProps { gateway: string hubId: string agentId: string + conversationId?: string expirySeconds?: number size?: number } @@ -125,6 +127,7 @@ export function ConnectionQRCode({ gateway, hubId, agentId, + conversationId, expirySeconds = 30, size = 200, }: ConnectionQRCodeProps) { @@ -138,10 +141,11 @@ export function ConnectionQRCode({ gateway, hubId, agentId, + conversationId: conversationId ?? agentId, token, expires: expiresAt, }), - [gateway, hubId, agentId, token, expiresAt] + [gateway, hubId, agentId, conversationId, token, expiresAt] ) const connectionUrl = useMemo(() => { @@ -149,11 +153,12 @@ export function ConnectionQRCode({ gateway, hub: hubId, agent: agentId, + conversation: conversationId ?? agentId, token, exp: expiresAt.toString(), }) return `multica://connect?${params.toString()}` - }, [gateway, hubId, agentId, token, expiresAt]) + }, [gateway, hubId, agentId, conversationId, token, expiresAt]) return (
diff --git a/apps/desktop/src/renderer/src/components/telegram-qr.tsx b/apps/desktop/src/renderer/src/components/telegram-qr.tsx index cc99679d..c8a077ff 100644 --- a/apps/desktop/src/renderer/src/components/telegram-qr.tsx +++ b/apps/desktop/src/renderer/src/components/telegram-qr.tsx @@ -8,6 +8,7 @@ export interface TelegramConnectQRProps { gateway: string hubId: string agentId: string + conversationId?: string expirySeconds?: number size?: number } @@ -23,6 +24,7 @@ export function TelegramConnectQR({ gateway, hubId, agentId, + conversationId, expirySeconds = 30, size = 200, }: TelegramConnectQRProps) { @@ -44,7 +46,14 @@ export function TelegramConnectQR({ const res = await fetch(`${gateway}/telegram/connect-code`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gateway, hubId, agentId, token, expires: expiresAt }), + body: JSON.stringify({ + gateway, + hubId, + agentId, + conversationId: conversationId ?? agentId, + token, + expires: expiresAt, + }), }) if (cancelled) return @@ -72,7 +81,7 @@ export function TelegramConnectQR({ fetchCode() return () => { cancelled = true } - }, [token, expiresAt, gateway, hubId, agentId]) + }, [token, expiresAt, gateway, hubId, agentId, conversationId]) if (loading) { return ( diff --git a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts index b75f6d53..fb7a8a7a 100644 --- a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts @@ -15,11 +15,16 @@ export interface QueuedLocalMessage { createdAt: number } +interface UseLocalChatOptions { + conversationId?: string +} + function makeQueueId(): string { return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } -export function useLocalChat() { +export function useLocalChat(options: UseLocalChatOptions = {}) { + const requestedConversationId = options.conversationId const chat = useChat() const chatRef = useRef(chat) chatRef.current = chat @@ -33,6 +38,7 @@ export function useLocalChat() { const [initError, setInitError] = useState(null) const initRef = useRef(false) const offsetRef = useRef(null) + const activeConversationId = requestedConversationId ?? agentId // Initialize hub and get default agent ID useEffect(() => { @@ -41,10 +47,13 @@ export function useLocalChat() { 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) + const r = result as { defaultAgentId?: string; defaultConversationId?: string } + const defaultConversationId = r.defaultConversationId ?? r.defaultAgentId + console.log('[LocalChat] hub.init → defaultConversationId:', defaultConversationId) + if (defaultConversationId) { + setAgentId(defaultConversationId) + } else if (requestedConversationId) { + setAgentId(requestedConversationId) } else { setInitError('No default agent available') setIsLoadingHistory(false) @@ -54,14 +63,20 @@ export function useLocalChat() { setInitError(err.message) setIsLoadingHistory(false) }) - }, []) + }, [requestedConversationId]) - // Subscribe to events + fetch history once agentId is available + // Subscribe to events + fetch history once conversation is available useEffect(() => { - if (!agentId) return + if (!activeConversationId) return + const resolvedAgentId = agentId ?? activeConversationId + setQueuedMessages([]) + offsetRef.current = null + setIsLoading(false) + setIsLoadingHistory(true) + chatRef.current.reset() // Subscribe to agent events - window.electronAPI.localChat.subscribe(agentId).catch(() => {}) + window.electronAPI.localChat.subscribe(activeConversationId).catch(() => {}) // Listen for stream events window.electronAPI.localChat.onEvent((data) => { @@ -104,24 +119,39 @@ export function useLocalChat() { // Listen for inbound messages from all sources (gateway, channel) // This allows the local UI to display messages from other sources window.electronAPI.hub.onInboundMessage((event: InboundMessageEvent) => { + const eventConversationId = event.conversationId ?? event.agentId // Only add non-local messages (local messages are added by sendMessage) - if (event.source.type !== 'local' && event.agentId === agentId) { - chatRef.current.addUserMessage(event.content, event.agentId, event.source as MessageSource) + if (event.source.type !== 'local' && eventConversationId === activeConversationId) { + chatRef.current.addUserMessage( + event.content, + event.agentId, + event.source as MessageSource, + eventConversationId, + ) setIsLoading(true) } }) // Fetch history with pagination - window.electronAPI.localChat.getHistory(agentId, { limit: DEFAULT_MESSAGES_LIMIT }) + window.electronAPI.localChat.getHistory(resolvedAgentId, { + limit: DEFAULT_MESSAGES_LIMIT, + conversationId: activeConversationId, + }) .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, { + chatRef.current.setHistory(result.messages as AgentMessageItem[], resolvedAgentId, { total: result.total, offset: result.offset, contextWindowTokens: result.contextWindowTokens, - }) + }, activeConversationId) offsetRef.current = result.offset + } else { + chatRef.current.setHistory([], resolvedAgentId, { + total: 0, + offset: 0, + contextWindowTokens: result.contextWindowTokens, + }, activeConversationId) } }) .catch(() => {}) @@ -131,9 +161,9 @@ export function useLocalChat() { window.electronAPI.localChat.offEvent() window.electronAPI.localChat.offApproval() window.electronAPI.hub.offInboundMessage() - window.electronAPI.localChat.unsubscribe(agentId).catch(() => {}) + window.electronAPI.localChat.unsubscribe(activeConversationId).catch(() => {}) } - }, [agentId]) + }, [agentId, activeConversationId]) useEffect(() => { isLoadingRef.current = isLoading @@ -141,11 +171,12 @@ export function useLocalChat() { const dispatchMessageNow = useCallback((text: string) => { const trimmed = text.trim() - if (!trimmed || !agentId) return - chatRef.current.addUserMessage(trimmed, agentId, { type: 'local' }) + if (!trimmed || !activeConversationId) return + const resolvedAgentId = agentId ?? activeConversationId + chatRef.current.addUserMessage(trimmed, resolvedAgentId, { type: 'local' }, activeConversationId) chatRef.current.setError(null) setIsLoading(true) - window.electronAPI.localChat.send(agentId, trimmed) + window.electronAPI.localChat.send(resolvedAgentId, trimmed, activeConversationId) .then((result) => { const response = result as { ok?: boolean; error?: string } | undefined if (response?.error) { @@ -155,11 +186,11 @@ export function useLocalChat() { .catch(() => { setIsLoading(false) }) - }, [agentId]) + }, [agentId, activeConversationId]) const sendMessage = useCallback((text: string) => { const trimmed = text.trim() - if (!trimmed || !agentId) return + if (!trimmed || !activeConversationId) return if (isLoadingRef.current) { setQueuedMessages((prev) => [ @@ -174,7 +205,7 @@ export function useLocalChat() { } dispatchMessageNow(trimmed) - }, [agentId, dispatchMessageNow]) + }, [activeConversationId, dispatchMessageNow]) const removeQueuedMessage = useCallback((id: string) => { setQueuedMessages((prev) => prev.filter((item) => item.id !== id)) @@ -185,35 +216,41 @@ export function useLocalChat() { }, []) useEffect(() => { - if (!agentId || isLoading || queuedMessages.length === 0) return + if (!activeConversationId || isLoading || queuedMessages.length === 0) return const next = queuedMessages[0] if (!next) return setQueuedMessages((prev) => prev.slice(1)) dispatchMessageNow(next.text) - }, [agentId, isLoading, queuedMessages, dispatchMessageNow]) + }, [activeConversationId, isLoading, queuedMessages, dispatchMessageNow]) const abortGeneration = useCallback(() => { - if (!agentId) return - window.electronAPI.localChat.abort(agentId).catch(() => {}) + if (!activeConversationId) return + const resolvedAgentId = agentId ?? activeConversationId + window.electronAPI.localChat.abort(resolvedAgentId, activeConversationId).catch(() => {}) setIsLoading(false) - }, [agentId]) + }, [agentId, activeConversationId]) const loadMore = useCallback(async () => { const currentOffset = offsetRef.current - if (!agentId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return + if (!activeConversationId || currentOffset == null || currentOffset <= 0 || isLoadingMoreRef.current) return isLoadingMoreRef.current = true setIsLoadingMore(true) try { + const resolvedAgentId = agentId ?? activeConversationId const newOffset = Math.max(0, currentOffset - DEFAULT_MESSAGES_LIMIT) const limit = currentOffset - newOffset - const result = await window.electronAPI.localChat.getHistory(agentId, { offset: newOffset, limit }) + const result = await window.electronAPI.localChat.getHistory(resolvedAgentId, { + offset: newOffset, + limit, + conversationId: activeConversationId, + }) if (result.messages?.length) { - chatRef.current.prependHistory(result.messages as AgentMessageItem[], agentId, { + chatRef.current.prependHistory(result.messages as AgentMessageItem[], resolvedAgentId, { total: result.total, offset: result.offset, contextWindowTokens: result.contextWindowTokens, - }) + }, activeConversationId) offsetRef.current = result.offset } } catch { @@ -222,7 +259,7 @@ export function useLocalChat() { isLoadingMoreRef.current = false setIsLoadingMore(false) } - }, [agentId]) + }, [agentId, activeConversationId]) const resolveApproval = useCallback( (approvalId: string, decision: ApprovalDecision) => { @@ -238,6 +275,7 @@ export function useLocalChat() { return { agentId, + conversationId: activeConversationId, initError, messages: chat.messages, streamingIds: chat.streamingIds, diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx index 9cef56e4..19e5a702 100644 --- a/apps/desktop/src/renderer/src/pages/layout.tsx +++ b/apps/desktop/src/renderer/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { MessageSquare, Users, Clock, + Plus, ChevronLeft, ChevronRight, ChevronDown, @@ -48,6 +49,7 @@ import { LocalChat } from '../components/local-chat' import { DeviceConfirmDialog } from '../components/device-confirm-dialog' import { UpdateNotification } from '../components/update-notification' import { useAuthStore } from '../stores/auth' +import { useHubStore } from '../stores/hub' const mainNavItems = [ { path: '/', label: 'Home', icon: Home, exact: true }, @@ -72,6 +74,11 @@ const allNavItems: Array<{ path: string; label: string; icon: typeof Home; exact ...bottomNavItems, ] +function shortConversationId(id: string): string { + if (id.length <= 18) return id + return `${id.slice(0, 8)}...${id.slice(-8)}` +} + function NavigationButtons() { const navigate = useNavigate() useLocation() @@ -155,6 +162,13 @@ export default function Layout() { const isAgentActive = location.pathname.startsWith('/agent') const isOnChat = location.pathname === '/chat' const { user, clearAuth } = useAuthStore() + const { agents, refresh: refreshAgents, createConversation } = useHubStore() + const [isCreatingConversation, setIsCreatingConversation] = useState(false) + const [conversationError, setConversationError] = useState(null) + const selectedConversationId = isOnChat + ? new URLSearchParams(location.search).get('conversation') ?? undefined + : undefined + const activeConversationId = selectedConversationId ?? agents[0]?.id // Lazy mount: only mount Chat on first visit, then keep it mounted forever const [chatMounted, setChatMounted] = useState(false) @@ -162,6 +176,19 @@ export default function Layout() { if (isOnChat && !chatMounted) setChatMounted(true) }, [isOnChat, chatMounted]) + useEffect(() => { + if (!isOnChat) return + void refreshAgents() + }, [isOnChat, refreshAgents]) + + useEffect(() => { + if (!isOnChat || !selectedConversationId || agents.length === 0) return + const exists = agents.some((item) => item.id === selectedConversationId) + if (!exists) { + navigate('/chat', { replace: true }) + } + }, [isOnChat, selectedConversationId, agents, navigate]) + // Extract initialPrompt from URL search params when navigating to /chat?prompt=... const initialPrompt = isOnChat ? new URLSearchParams(location.search).get('prompt') ?? undefined @@ -172,6 +199,29 @@ export default function Layout() { navigate('/login') } + const openConversation = (id: string) => { + if (!id) return + navigate(`/chat?conversation=${encodeURIComponent(id)}`) + } + + const handleCreateConversation = async () => { + setConversationError(null) + setIsCreatingConversation(true) + try { + const created = await createConversation() + if (!created?.id) { + setConversationError('Failed to create session') + return + } + openConversation(created.id) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setConversationError(message) + } finally { + setIsCreatingConversation(false) + } + } + return (
@@ -304,7 +354,56 @@ export default function Layout() {
{chatMounted && (
- +
+
+ Session + {activeConversationId ? ( + + + } + > + {shortConversationId(activeConversationId)} + + + {agents.length === 0 ? ( + + No sessions available + + ) : ( + agents.map((item) => ( + openConversation(item.id)}> + {item.id} + {item.id === activeConversationId && Current} + + )) + )} + + + ) : ( + Initializing... + )} +
+ +
+ + {conversationError && ( +
+ {conversationError} +
+ )} + +
)} diff --git a/apps/desktop/src/renderer/src/stores/hub.ts b/apps/desktop/src/renderer/src/stores/hub.ts index 841ab25b..61044880 100644 --- a/apps/desktop/src/renderer/src/stores/hub.ts +++ b/apps/desktop/src/renderer/src/stores/hub.ts @@ -27,6 +27,8 @@ interface HubStore { init: () => Promise refresh: () => Promise reconnect: (url: string) => Promise<{ ok: boolean; error?: string }> + createConversation: (id?: string) => Promise + closeConversation: (id: string) => Promise } export const useHubStore = create()((set, get) => ({ @@ -101,6 +103,34 @@ export const useHubStore = create()((set, get) => ({ return { ok: false, error: message } } }, + + createConversation: async (id?: string) => { + try { + const result = await window.electronAPI.hub.createConversation(id) as { id?: string; closed?: boolean } + await get().refresh() + if (!result?.id) return null + return { + id: result.id, + closed: result.closed ?? false, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return null + } + }, + + closeConversation: async (id: string) => { + try { + const result = await window.electronAPI.hub.closeConversation(id) as { ok?: boolean } + await get().refresh() + return !!result?.ok + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ error: message }) + return false + } + }, })) // Selector helpers