diff --git a/web/src/hooks/mutations/useSendMessage.test.tsx b/web/src/hooks/mutations/useSendMessage.test.tsx index 20d57440e..6344679b0 100644 --- a/web/src/hooks/mutations/useSendMessage.test.tsx +++ b/web/src/hooks/mutations/useSendMessage.test.tsx @@ -158,21 +158,48 @@ describe('useSendMessage', () => { await expect(acceptedPromise!).resolves.toBe(false) }) - it('resolves false when resolveSessionId throws (inactive-session resume failure)', async () => { - const api = createMockApi() + it('resolves false and keeps a failed prompt bubble when resolveSessionId throws', async () => { + const sendMock = vi.fn() + const api = createMockApi(sendMock) const resumeError = new Error('resume failed') - const { result } = renderHook( - () => useSendMessage(api, 'session-A', { - resolveSessionId: async () => { throw resumeError }, - onSessionResolved: vi.fn(), - }), - { wrapper: createWrapper() }, - ) - let acceptedPromise: Promise | undefined - act(() => { - acceptedPromise = result.current.sendMessage('hello') - }) - await expect(acceptedPromise!).resolves.toBe(false) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { appendOptimisticMessage } = await import('@/lib/message-window-store') + try { + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { + resolveSessionId: async () => { throw resumeError }, + onSessionResolved: vi.fn(), + }), + { wrapper: createWrapper() }, + ) + let acceptedPromise: Promise | undefined + act(() => { + acceptedPromise = result.current.sendMessage('hello') + }) + await expect(acceptedPromise!).resolves.toBe(false) + expect(sendMock).not.toHaveBeenCalled() + expect(appendOptimisticMessage).toHaveBeenCalledWith( + 'session-A', + expect.objectContaining({ + id: 'local-id-1', + localId: 'local-id-1', + invokedAt: null, + status: 'failed', + originalText: 'hello', + content: { + role: 'user', + content: { + type: 'text', + text: 'hello', + attachments: undefined, + } + }, + }) + ) + expect(consoleError).toHaveBeenCalledWith('Failed to resolve session before send:', resumeError) + } finally { + consoleError.mockRestore() + } }) it('resolves true after async resolveSessionId succeeds and mutation starts', async () => { @@ -234,4 +261,53 @@ describe('useSendMessage', () => { scheduledAt, ) }) + + it('resolves the inactive session before retrying a preserved failed prompt', async () => { + const sendMock = vi.fn(async () => {}) + const onSessionResolved = vi.fn() + const resolveSessionId = vi.fn(async () => 'session-resolved') + const api = createMockApi(sendMock) + const { getMessageWindowState, updateMessageStatus } = await import('@/lib/message-window-store') + vi.mocked(getMessageWindowState).mockReturnValueOnce({ + messages: [{ + id: 'local-retry-1', + seq: null, + localId: 'local-retry-1', + content: { role: 'user', content: { type: 'text', text: 'preserved prompt' } }, + createdAt: 1_000, + invokedAt: null, + scheduledAt: null, + status: 'failed', + originalText: 'preserved prompt', + } as never], + pending: [], + } as never) + + const { result } = renderHook( + () => useSendMessage(api, 'session-A', { + resolveSessionId, + onSessionResolved, + }), + { wrapper: createWrapper() }, + ) + + act(() => { + expect(result.current.retryMessage('local-retry-1')).toBe(true) + }) + + await waitFor(() => { + expect(sendMock).toHaveBeenCalled() + }) + + expect(resolveSessionId).toHaveBeenCalledWith('session-A') + expect(onSessionResolved).toHaveBeenCalledWith('session-resolved') + expect(updateMessageStatus).toHaveBeenCalledWith('session-A', 'local-retry-1', 'sending') + expect(sendMock).toHaveBeenCalledWith( + 'session-resolved', + 'preserved prompt', + 'local-retry-1', + undefined, + null, + ) + }) }) diff --git a/web/src/hooks/mutations/useSendMessage.ts b/web/src/hooks/mutations/useSendMessage.ts index dc54c83f9..500c514bf 100644 --- a/web/src/hooks/mutations/useSendMessage.ts +++ b/web/src/hooks/mutations/useSendMessage.ts @@ -31,7 +31,7 @@ type UseSendMessageOptions = { /** 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 { +function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'sending' | 'failed'): DecryptedMessage { return { id: input.localId, seq: null, @@ -145,6 +145,21 @@ export function useSendMessage( targetSessionId = resolved } } catch (error) { + // The composer has already cleared its text by the time async + // inactive-session resume fails. Surface a failed local prompt + // bubble so 500/502 resume errors cannot make the user's prompt + // disappear with no way to copy or retry it. + appendOptimisticMessage( + sessionId, + createOptimisticMessage({ + sessionId, + text, + localId, + createdAt, + attachments, + scheduledAt, + }, 'failed') + ) haptic.notification('error') console.error('Failed to resolve session before send:', error) return false @@ -182,16 +197,40 @@ export function useSendMessage( const message = findMessageByLocalId(sessionId, localId) if (!message?.originalText) return false + const originalText = message.originalText updateMessageStatus(sessionId, localId, 'sending') - mutation.mutate({ - sessionId, - text: message.originalText, - localId, - createdAt: message.createdAt, - scheduledAt: message.scheduledAt ?? null, - }) + void (async () => { + 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) { + updateMessageStatus(sessionId, localId, 'failed') + haptic.notification('error') + console.error('Failed to resolve session before retry:', error) + return + } finally { + resolveGuardRef.current = false + setIsResolving(false) + } + } + + mutation.mutate({ + sessionId: targetSessionId, + text: originalText, + localId, + createdAt: message.createdAt, + scheduledAt: message.scheduledAt ?? null, + }) + })() return true }