diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 1ab88f29e..6ec5a18df 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -107,7 +107,7 @@ export function HappyComposer(props: { const path = (attachment as { path?: string }).path return typeof path === 'string' && path.length > 0 }) - const canSend = (hasText || hasAttachments) && attachmentsReady && !controlsDisabled && !threadIsRunning + const canSend = (hasText || hasAttachments) && attachmentsReady && !controlsDisabled const [inputState, setInputState] = useState({ text: '', diff --git a/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx b/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx index 5b5385b4c..dc5780d9f 100644 --- a/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx +++ b/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx @@ -1,4 +1,5 @@ import type { MessageStatus } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' function ErrorIcon() { return ( @@ -14,6 +15,15 @@ export function MessageStatusIndicator(props: { status?: MessageStatus onRetry?: () => void }) { + const { t } = useTranslation() + if (props.status === 'queued') { + return ( + + {t('message.status.queued')} + + ) + } + if (props.status !== 'failed') { return null } @@ -29,7 +39,7 @@ export function MessageStatusIndicator(props: { onClick={props.onRetry} className="text-xs text-blue-500 hover:underline" > - Retry + {t('message.status.retry')} ) : null} diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts index 6ca5ae638..9ecd6b2c0 100644 --- a/web/src/hooks/mutations/useSendMessage.ts +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ApiClient } from '@/api/client' -import type { AttachmentMetadata, DecryptedMessage } from '@/types/api' +import type { AttachmentMetadata, DecryptedMessage, MessageStatus } from '@/types/api' import { makeClientSideId } from '@/lib/messages' import { appendOptimisticMessage, @@ -16,6 +16,7 @@ type SendMessageInput = { localId: string createdAt: number attachments?: AttachmentMetadata[] + appendOptimistic?: boolean } type BlockedReason = 'no-api' | 'no-session' | 'pending' @@ -24,6 +25,27 @@ type UseSendMessageOptions = { resolveSessionId?: (sessionId: string) => Promise onSessionResolved?: (sessionId: string) => void onBlocked?: (reason: BlockedReason) => void + isSessionRunning?: boolean + enableQueue?: boolean +} + +function createOptimisticMessage(input: SendMessageInput, status: MessageStatus): DecryptedMessage { + return { + id: input.localId, + seq: null, + localId: input.localId, + content: { + role: 'user', + content: { + type: 'text', + text: input.text, + attachments: input.attachments + } + }, + createdAt: input.createdAt, + status, + originalText: input.text, + } } function findMessageByLocalId( @@ -51,6 +73,8 @@ export function useSendMessage( } { const { haptic } = usePlatform() const [isResolving, setIsResolving] = useState(false) + const [isDequeuing, setIsDequeuing] = useState(false) + const [queuedMessages, setQueuedMessages] = useState([]) const resolveGuardRef = useRef(false) const mutation = useMutation({ @@ -61,24 +85,10 @@ export function useSendMessage( await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments) }, onMutate: async (input) => { - const optimisticMessage: DecryptedMessage = { - id: input.localId, - seq: null, - localId: input.localId, - content: { - role: 'user', - content: { - type: 'text', - text: input.text, - attachments: input.attachments - } - }, - createdAt: input.createdAt, - status: 'sending', - originalText: input.text, + if (input.appendOptimistic === false) { + return } - - appendOptimisticMessage(input.sessionId, optimisticMessage) + appendOptimisticMessage(input.sessionId, createOptimisticMessage(input, 'sending')) }, onSuccess: (_, input) => { updateMessageStatus(input.sessionId, input.localId, 'sent') @@ -88,8 +98,30 @@ export function useSendMessage( updateMessageStatus(input.sessionId, input.localId, 'failed') haptic.notification('error') }, + onSettled: () => { + setIsDequeuing(false) + } }) + const busy = mutation.isPending || resolveGuardRef.current || isResolving || isDequeuing + const running = options?.isSessionRunning === true + const canQueue = options?.enableQueue === true + + useEffect(() => { + if (!api || busy || running || queuedMessages.length === 0) { + return + } + + const [next, ...rest] = queuedMessages + setQueuedMessages(rest) + setIsDequeuing(true) + updateMessageStatus(next.sessionId, next.localId, 'sending') + mutation.mutate({ + ...next, + appendOptimistic: false + }) + }, [api, busy, mutation, queuedMessages, running]) + const sendMessage = (text: string, attachments?: AttachmentMetadata[]) => { if (!api) { options?.onBlocked?.('no-api') @@ -101,12 +133,28 @@ export function useSendMessage( haptic.notification('error') return } - if (mutation.isPending || resolveGuardRef.current) { + const localId = makeClientSideId('local') + const createdAt = Date.now() + + if ((busy || running) && canQueue) { + const queuedInput: SendMessageInput = { + sessionId, + text, + localId, + createdAt, + attachments, + appendOptimistic: false + } + appendOptimisticMessage(sessionId, createOptimisticMessage(queuedInput, 'queued')) + setQueuedMessages(prev => [...prev, queuedInput]) + haptic.impact('light') + return + } + + if (busy) { options?.onBlocked?.('pending') return } - const localId = makeClientSideId('local') - const createdAt = Date.now() void (async () => { let targetSessionId = sessionId if (options?.resolveSessionId) { @@ -133,6 +181,7 @@ export function useSendMessage( localId, createdAt, attachments, + appendOptimistic: true }) })() } @@ -148,7 +197,7 @@ export function useSendMessage( haptic.notification('error') return } - if (mutation.isPending || resolveGuardRef.current) { + if (busy) { options?.onBlocked?.('pending') return } @@ -169,6 +218,6 @@ export function useSendMessage( return { sendMessage, retryMessage, - isSending: mutation.isPending || isResolving, + isSending: mutation.isPending || isResolving || isDequeuing, } } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 9c67e8d25..98b4c9988 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -80,6 +80,8 @@ export default { 'dialog.delete.confirm': 'Delete', 'dialog.delete.confirming': 'Deleting…', 'dialog.error.default': 'Operation failed. Please try again.', + 'message.status.queued': 'Queued', + 'message.status.retry': 'Retry', // Common buttons 'button.cancel': 'Cancel', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fa218ed91..950b70ee1 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -82,6 +82,8 @@ export default { 'dialog.delete.confirm': '删除', 'dialog.delete.confirming': '删除中…', 'dialog.error.default': '操作失败,请重试。', + 'message.status.queued': '已排队', + 'message.status.retry': '重试', // Common buttons 'button.cancel': '取消', diff --git a/web/src/router.tsx b/web/src/router.tsx index ef8030005..deee4b47b 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -261,7 +261,9 @@ function SessionPage() { }) } // 'no-session' and 'pending' don't need toast - either invalid state or expected behavior - } + }, + isSessionRunning: Boolean(session?.thinking), + enableQueue: Boolean(session?.active) }) // Get agent type from session metadata for slash commands diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 14a7b6c6b..adf1ede8c 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -35,7 +35,7 @@ export type SessionMetadataSummary = { worktree?: WorktreeMetadata } -export type MessageStatus = 'sending' | 'sent' | 'failed' +export type MessageStatus = 'queued' | 'sending' | 'sent' | 'failed' export type DecryptedMessage = ProtocolDecryptedMessage & { status?: MessageStatus