Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/components/AssistantChat/HappyComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextInputState>({
text: '',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MessageStatus } from '@/types/api'
import { useTranslation } from '@/lib/use-translation'

function ErrorIcon() {
return (
Expand All @@ -14,6 +15,15 @@ export function MessageStatusIndicator(props: {
status?: MessageStatus
onRetry?: () => void
}) {
const { t } = useTranslation()
if (props.status === 'queued') {
return (
<span className="text-xs text-[var(--app-hint)]">
{t('message.status.queued')}
</span>
)
}

if (props.status !== 'failed') {
return null
}
Expand All @@ -29,7 +39,7 @@ export function MessageStatusIndicator(props: {
onClick={props.onRetry}
className="text-xs text-blue-500 hover:underline"
>
Retry
{t('message.status.retry')}
</button>
) : null}
</span>
Expand Down
97 changes: 73 additions & 24 deletions web/src/hooks/mutations/useSendMessage.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,6 +16,7 @@ type SendMessageInput = {
localId: string
createdAt: number
attachments?: AttachmentMetadata[]
appendOptimistic?: boolean
}

type BlockedReason = 'no-api' | 'no-session' | 'pending'
Expand All @@ -24,6 +25,27 @@ type UseSendMessageOptions = {
resolveSessionId?: (sessionId: string) => Promise<string>
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(
Expand Down Expand Up @@ -51,6 +73,8 @@ export function useSendMessage(
} {
const { haptic } = usePlatform()
const [isResolving, setIsResolving] = useState(false)
const [isDequeuing, setIsDequeuing] = useState(false)
const [queuedMessages, setQueuedMessages] = useState<SendMessageInput[]>([])
const resolveGuardRef = useRef(false)

const mutation = useMutation({
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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) {
Expand All @@ -133,6 +181,7 @@ export function useSendMessage(
localId,
createdAt,
attachments,
appendOptimistic: true
})
})()
}
Expand All @@ -148,7 +197,7 @@ export function useSendMessage(
haptic.notification('error')
return
}
if (mutation.isPending || resolveGuardRef.current) {
if (busy) {
options?.onBlocked?.('pending')
return
}
Expand All @@ -169,6 +218,6 @@ export function useSendMessage(
return {
sendMessage,
retryMessage,
isSending: mutation.isPending || isResolving,
isSending: mutation.isPending || isResolving || isDequeuing,
}
}
2 changes: 2 additions & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '取消',
Expand Down
4 changes: 3 additions & 1 deletion web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion web/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down