feat(desktop): queue user messages while agent is busy

This commit is contained in:
Jiayuan Zhang 2026-02-17 01:19:39 +08:00
parent 6e71598c2c
commit bfe0c82e87
3 changed files with 136 additions and 11 deletions

View file

@ -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={
<QueuedMessageBar
messages={queuedMessages}
isRunning={isLoading}
onRemove={removeQueuedMessage}
onClear={clearQueuedMessages}
/>
}
/>
{currentMeta && currentMeta.authMethod === 'api-key' && (

View file

@ -0,0 +1,53 @@
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) {
if (messages.length === 0) return null
const statusText = isRunning
? 'Agent is running. Queued messages will send automatically.'
: 'Queued messages are being sent.'
return (
<div className="container px-4 pb-2">
<div className="rounded-lg border bg-muted/40">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<span className="text-xs font-medium text-foreground/80">
{messages.length} queued message{messages.length > 1 ? 's' : ''}
</span>
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
<div className="px-3 pb-2 text-xs text-muted-foreground">{statusText}</div>
<div className="px-2 pb-2 space-y-1">
{messages.slice(0, 3).map((item) => (
<div key={item.id} className="flex items-start justify-between gap-2 rounded-md bg-background/70 px-2 py-1.5">
<div className="text-xs text-foreground/85 break-all">{item.text}</div>
<button
onClick={() => onRemove(item.id)}
className="shrink-0 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Remove
</button>
</div>
))}
{messages.length > 3 && (
<div className="px-1 text-xs text-muted-foreground">
+{messages.length - 3} more
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -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<string | null>(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<QueuedLocalMessage[]>([])
const [initError, setInitError] = useState<string | null>(null)
const initRef = useRef(false)
const offsetRef = useRef<number | null>(null)
@ -109,17 +121,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 +234,11 @@ export function useLocalChat() {
contextWindowTokens: chat.contextWindowTokens,
error: chat.error,
pendingApprovals: chat.pendingApprovals,
queuedMessages,
sendMessage,
abortGeneration,
removeQueuedMessage,
clearQueuedMessages,
loadMore,
resolveApproval,
clearError,