(
api: ApiClient | null,
sessionId: string | null,
options?: UseSendMessageOptions
)
| 133 | } |
| 134 | |
| 135 | export function useSendMessage( |
| 136 | api: ApiClient | null, |
| 137 | sessionId: string | null, |
| 138 | options?: UseSendMessageOptions |
| 139 | ): { |
| 140 | // Resolves true when a mutation was actually started, false when the call was |
| 141 | // rejected pre-mutation (no-api / no-session / pending) OR the async |
| 142 | // resolveSessionId step threw. Async is required because inactive-session |
| 143 | // resume happens before mutation.mutate(), and a sync `true` would let the |
| 144 | // caller clear UI state (e.g. pendingSchedule) before knowing whether |
| 145 | // resume succeeded — see SessionChat.handleSend. |
| 146 | sendMessage: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean> |
| 147 | retryMessage: (localId: string) => boolean |
| 148 | isSending: boolean |
| 149 | } { |
| 150 | const { haptic } = usePlatform() |
| 151 | const [isResolving, setIsResolving] = useState(false) |
| 152 | const resolveGuardRef = useRef(false) |
| 153 | const isSessionThinkingRef = useRef(options?.isSessionThinking ?? false) |
| 154 | isSessionThinkingRef.current = options?.isSessionThinking ?? false |
| 155 | |
| 156 | const mutation = useMutation({ |
| 157 | mutationFn: async (input: SendMessageInput) => { |
| 158 | if (!api) { |
| 159 | throw new Error('API unavailable') |
| 160 | } |
| 161 | await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments, input.scheduledAt) |
| 162 | }, |
| 163 | onMutate: async (input) => { |
| 164 | const status = isSessionThinkingRef.current ? 'queued' as const : 'sending' as const |
| 165 | appendOptimisticMessage(input.sessionId, createOptimisticMessage(input, status)) |
| 166 | return { status } |
| 167 | }, |
| 168 | onSuccess: (_, input, context) => { |
| 169 | updateMessageStatus( |
| 170 | input.sessionId, |
| 171 | input.localId, |
| 172 | context?.status === 'queued' ? 'queued' : 'sent' |
| 173 | ) |
| 174 | haptic.notification('success') |
| 175 | options?.onSuccess?.(input.sessionId) |
| 176 | }, |
| 177 | onError: (error, input) => { |
| 178 | // Attachment sends keep the legacy failed-bubble UX: the |
| 179 | // composer-restore path can only re-seat text + scheduledAt, |
| 180 | // not the uploaded attachment metadata. Removing the row |
| 181 | // would destroy the attachment preview AND leave the operator |
| 182 | // with no retry surface for it. Keep the row as `failed` so |
| 183 | // the in-thread retry button can re-fire the send (with |
| 184 | // attachments) via retryMessage. |
| 185 | if (input.attachments && input.attachments.length > 0) { |
| 186 | updateMessageStatus(input.sessionId, input.localId, 'failed') |
| 187 | haptic.notification('error') |
| 188 | return |
| 189 | } |
| 190 | // Text-only sends use the composer-restore path: drop the |
| 191 | // optimistic row from the thread (otherwise the failed bubble |
| 192 | // would visually duplicate the same text the composer is |
no test coverage detected