diff --git a/apps/desktop/src/renderer/src/components/local-chat.tsx b/apps/desktop/src/renderer/src/components/local-chat.tsx index 2e083111..469f0e49 100644 --- a/apps/desktop/src/renderer/src/components/local-chat.tsx +++ b/apps/desktop/src/renderer/src/components/local-chat.tsx @@ -6,6 +6,7 @@ import { useLocalChat } from '../hooks/use-local-chat' import { useProviderStore } from '../stores/provider' import { ApiKeyDialog } from './api-key-dialog' import { OAuthDialog } from './oauth-dialog' +import { QueuedMessageBar } from './queued-message-bar' interface LocalChatProps { initialPrompt?: string @@ -25,8 +26,11 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { contextWindowTokens, error, pendingApprovals, + queuedMessages, sendMessage, abortGeneration, + removeQueuedMessage, + clearQueuedMessages, loadMore, resolveApproval, clearError, @@ -119,6 +123,14 @@ export function LocalChat({ initialPrompt }: LocalChatProps) { loadMore={loadMore} resolveApproval={resolveApproval} errorAction={errorAction} + bottomSlot={ + + } /> {currentMeta && currentMeta.authMethod === 'api-key' && ( diff --git a/apps/desktop/src/renderer/src/components/queued-message-bar.tsx b/apps/desktop/src/renderer/src/components/queued-message-bar.tsx new file mode 100644 index 00000000..9673ad38 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/queued-message-bar.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import type { QueuedLocalMessage } from '../hooks/use-local-chat' + +interface QueuedMessageBarProps { + messages: QueuedLocalMessage[] + isRunning: boolean + onRemove: (id: string) => void + onClear: () => void +} + +export function QueuedMessageBar({ messages, isRunning, onRemove, onClear }: QueuedMessageBarProps) { + const [expanded, setExpanded] = useState(false) + + const statusText = isRunning + ? 'Agent is running. Queued messages will send automatically.' + : 'Queued messages are being sent.' + + if (messages.length === 0) return null + const firstMessage = messages[0] + const firstMessagePreview = + firstMessage.text.length <= 120 + ? firstMessage.text + : `${firstMessage.text.slice(0, 120)}...` + + return ( +
+
+
+
+ {messages.length} queued message{messages.length > 1 ? 's' : ''} +
+
+ {messages.length > 1 && ( + + )} + +
+
+
{statusText}
+ {expanded ? ( +
+
+ {messages.map((item) => ( +
+
{item.text}
+ +
+ ))} +
+
+ ) : ( +
+
+
{firstMessagePreview}
+ +
+ {messages.length > 1 && ( +
+ +{messages.length - 1} more +
+ )} +
+ )} +
+
+ ) +} 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 6dd512d3..b75f6d53 100644 --- a/apps/desktop/src/renderer/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/renderer/src/hooks/use-local-chat.ts @@ -9,15 +9,27 @@ import type { } from '@multica/sdk' import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk' +export interface QueuedLocalMessage { + id: string + text: string + createdAt: number +} + +function makeQueueId(): string { + return globalThis.crypto?.randomUUID?.() ?? `queued-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + export function useLocalChat() { const chat = useChat() const chatRef = useRef(chat) chatRef.current = chat const [agentId, setAgentId] = useState(null) const [isLoading, setIsLoading] = useState(false) + const isLoadingRef = useRef(false) const [isLoadingHistory, setIsLoadingHistory] = useState(true) const [isLoadingMore, setIsLoadingMore] = useState(false) const isLoadingMoreRef = useRef(false) + const [queuedMessages, setQueuedMessages] = useState([]) const [initError, setInitError] = useState(null) const initRef = useRef(false) const offsetRef = useRef(null) @@ -67,7 +79,21 @@ export function useLocalChat() { chatRef.current.handleStream(payload) if (payload.event.type === 'message_start') setIsLoading(true) - if (payload.event.type === 'message_end') setIsLoading(false) + if (payload.event.type === 'tool_execution_start') setIsLoading(true) + if (payload.event.type === 'message_end') { + const stopReason = + 'message' in payload.event + ? (payload.event.message as { stopReason?: string } | undefined)?.stopReason + : undefined + + // message_end with stopReason=toolUse is an intermediate step in the same run. + // Keep loading=true so queued user messages are not dispatched mid-run. + if (stopReason === 'toolUse') { + setIsLoading(true) + } else { + setIsLoading(false) + } + } }) // Listen for exec approval requests @@ -109,17 +135,62 @@ export function useLocalChat() { } }, [agentId]) - const sendMessage = useCallback( - (text: string) => { - const trimmed = text.trim() - if (!trimmed || !agentId) return - chatRef.current.addUserMessage(trimmed, agentId, { type: 'local' }) - chatRef.current.setError(null) - window.electronAPI.localChat.send(agentId, trimmed).catch(() => {}) - setIsLoading(true) - }, - [agentId], - ) + useEffect(() => { + isLoadingRef.current = isLoading + }, [isLoading]) + + const dispatchMessageNow = useCallback((text: string) => { + const trimmed = text.trim() + if (!trimmed || !agentId) return + chatRef.current.addUserMessage(trimmed, agentId, { type: 'local' }) + chatRef.current.setError(null) + setIsLoading(true) + window.electronAPI.localChat.send(agentId, trimmed) + .then((result) => { + const response = result as { ok?: boolean; error?: string } | undefined + if (response?.error) { + setIsLoading(false) + } + }) + .catch(() => { + setIsLoading(false) + }) + }, [agentId]) + + const sendMessage = useCallback((text: string) => { + const trimmed = text.trim() + if (!trimmed || !agentId) return + + if (isLoadingRef.current) { + setQueuedMessages((prev) => [ + ...prev, + { + id: makeQueueId(), + text: trimmed, + createdAt: Date.now(), + }, + ]) + return + } + + dispatchMessageNow(trimmed) + }, [agentId, dispatchMessageNow]) + + const removeQueuedMessage = useCallback((id: string) => { + setQueuedMessages((prev) => prev.filter((item) => item.id !== id)) + }, []) + + const clearQueuedMessages = useCallback(() => { + setQueuedMessages([]) + }, []) + + useEffect(() => { + if (!agentId || isLoading || queuedMessages.length === 0) return + const next = queuedMessages[0] + if (!next) return + setQueuedMessages((prev) => prev.slice(1)) + dispatchMessageNow(next.text) + }, [agentId, isLoading, queuedMessages, dispatchMessageNow]) const abortGeneration = useCallback(() => { if (!agentId) return @@ -177,8 +248,11 @@ export function useLocalChat() { contextWindowTokens: chat.contextWindowTokens, error: chat.error, pendingApprovals: chat.pendingApprovals, + queuedMessages, sendMessage, abortGeneration, + removeQueuedMessage, + clearQueuedMessages, loadMore, resolveApproval, clearError,