mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
204 lines
6.9 KiB
TypeScript
204 lines
6.9 KiB
TypeScript
import { useMutation } from '@tanstack/react-query'
|
|
import { useRef, useState } from 'react'
|
|
import type { ApiClient } from '@/api/client'
|
|
import type { AttachmentMetadata, DecryptedMessage } from '@/types/api'
|
|
import { makeClientSideId } from '@/lib/messages'
|
|
import {
|
|
appendOptimisticMessage,
|
|
getMessageWindowState,
|
|
updateMessageStatus,
|
|
} from '@/lib/message-window-store'
|
|
import { usePlatform } from '@/hooks/usePlatform'
|
|
|
|
type SendMessageInput = {
|
|
sessionId: string
|
|
text: string
|
|
localId: string
|
|
createdAt: number
|
|
attachments?: AttachmentMetadata[]
|
|
scheduledAt?: number | null
|
|
}
|
|
|
|
type BlockedReason = 'no-api' | 'no-session' | 'pending'
|
|
|
|
type UseSendMessageOptions = {
|
|
resolveSessionId?: (sessionId: string) => Promise<string>
|
|
onSessionResolved?: (sessionId: string) => void
|
|
onBlocked?: (reason: BlockedReason) => void
|
|
onSuccess?: (sessionId: string) => void
|
|
isSessionThinking?: boolean
|
|
}
|
|
|
|
/** Create an optimistic message for display. Extracted as an extension point
|
|
* so a future floating-UI PR can route queued messages to a separate area. */
|
|
function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'sending'): 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,
|
|
// Explicit null so the strict-null queued check matches. A pre-V8 hub
|
|
// response that omits the field entirely (`undefined`) is treated as
|
|
// already-invoked and stays in the thread, not the floating bar.
|
|
invokedAt: null,
|
|
scheduledAt: input.scheduledAt ?? null,
|
|
status,
|
|
originalText: input.text,
|
|
}
|
|
}
|
|
|
|
function findMessageByLocalId(
|
|
sessionId: string,
|
|
localId: string,
|
|
): DecryptedMessage | null {
|
|
const state = getMessageWindowState(sessionId)
|
|
for (const message of state.messages) {
|
|
if (message.localId === localId) return message
|
|
}
|
|
for (const message of state.pending) {
|
|
if (message.localId === localId) return message
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function useSendMessage(
|
|
api: ApiClient | null,
|
|
sessionId: string | null,
|
|
options?: UseSendMessageOptions
|
|
): {
|
|
// Resolves true when a mutation was actually started, false when the call was
|
|
// rejected pre-mutation (no-api / no-session / pending) OR the async
|
|
// resolveSessionId step threw. Async is required because inactive-session
|
|
// resume happens before mutation.mutate(), and a sync `true` would let the
|
|
// caller clear UI state (e.g. pendingSchedule) before knowing whether
|
|
// resume succeeded — see SessionChat.handleSend.
|
|
sendMessage: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
|
|
retryMessage: (localId: string) => boolean
|
|
isSending: boolean
|
|
} {
|
|
const { haptic } = usePlatform()
|
|
const [isResolving, setIsResolving] = useState(false)
|
|
const resolveGuardRef = useRef(false)
|
|
const isSessionThinkingRef = useRef(options?.isSessionThinking ?? false)
|
|
isSessionThinkingRef.current = options?.isSessionThinking ?? false
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (input: SendMessageInput) => {
|
|
if (!api) {
|
|
throw new Error('API unavailable')
|
|
}
|
|
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments, input.scheduledAt)
|
|
},
|
|
onMutate: async (input) => {
|
|
const status = isSessionThinkingRef.current ? 'queued' as const : 'sending' as const
|
|
appendOptimisticMessage(input.sessionId, createOptimisticMessage(input, status))
|
|
return { status }
|
|
},
|
|
onSuccess: (_, input, context) => {
|
|
updateMessageStatus(
|
|
input.sessionId,
|
|
input.localId,
|
|
context?.status === 'queued' ? 'queued' : 'sent'
|
|
)
|
|
haptic.notification('success')
|
|
options?.onSuccess?.(input.sessionId)
|
|
},
|
|
onError: (_, input) => {
|
|
updateMessageStatus(input.sessionId, input.localId, 'failed')
|
|
haptic.notification('error')
|
|
},
|
|
})
|
|
|
|
const sendMessage = async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<boolean> => {
|
|
if (!api) {
|
|
options?.onBlocked?.('no-api')
|
|
haptic.notification('error')
|
|
return false
|
|
}
|
|
if (!sessionId) {
|
|
options?.onBlocked?.('no-session')
|
|
haptic.notification('error')
|
|
return false
|
|
}
|
|
if (mutation.isPending || resolveGuardRef.current) {
|
|
options?.onBlocked?.('pending')
|
|
return false
|
|
}
|
|
const localId = makeClientSideId('local')
|
|
const createdAt = Date.now()
|
|
let targetSessionId = sessionId
|
|
if (options?.resolveSessionId) {
|
|
resolveGuardRef.current = true
|
|
setIsResolving(true)
|
|
try {
|
|
const resolved = await options.resolveSessionId(sessionId)
|
|
if (resolved && resolved !== sessionId) {
|
|
options.onSessionResolved?.(resolved)
|
|
targetSessionId = resolved
|
|
}
|
|
} catch (error) {
|
|
haptic.notification('error')
|
|
console.error('Failed to resolve session before send:', error)
|
|
return false
|
|
} finally {
|
|
resolveGuardRef.current = false
|
|
setIsResolving(false)
|
|
}
|
|
}
|
|
mutation.mutate({
|
|
sessionId: targetSessionId,
|
|
text,
|
|
localId,
|
|
createdAt,
|
|
attachments,
|
|
scheduledAt,
|
|
})
|
|
return true
|
|
}
|
|
|
|
const retryMessage = (localId: string): boolean => {
|
|
if (!api) {
|
|
options?.onBlocked?.('no-api')
|
|
haptic.notification('error')
|
|
return false
|
|
}
|
|
if (!sessionId) {
|
|
options?.onBlocked?.('no-session')
|
|
haptic.notification('error')
|
|
return false
|
|
}
|
|
if (mutation.isPending || resolveGuardRef.current) {
|
|
options?.onBlocked?.('pending')
|
|
return false
|
|
}
|
|
|
|
const message = findMessageByLocalId(sessionId, localId)
|
|
if (!message?.originalText) return false
|
|
|
|
updateMessageStatus(sessionId, localId, 'sending')
|
|
|
|
mutation.mutate({
|
|
sessionId,
|
|
text: message.originalText,
|
|
localId,
|
|
createdAt: message.createdAt,
|
|
scheduledAt: message.scheduledAt ?? null,
|
|
})
|
|
return true
|
|
}
|
|
|
|
return {
|
|
sendMessage,
|
|
retryMessage,
|
|
isSending: mutation.isPending || isResolving,
|
|
}
|
|
}
|