From fb3988a81fa9f2063d48a519ca2737f524f12ea2 Mon Sep 17 00:00:00 2001 From: KorenKrita Date: Mon, 3 Aug 2026 09:25:01 +0800 Subject: [PATCH] fix(web): restore failed sends atomically (#1326) * fix(web): restore failed sends atomically * fix(web): wait for composer draft hydration * fix(web): count restored attachments after success * fix(web): keep scratchlist copy available * fix(web): move suppressed retry errors to target session --- .../HappyComposer.sendError.test.tsx | 413 ++++++++++++++++++ .../AssistantChat/HappyComposer.tsx | 214 +++++++-- .../AssistantChat/ScratchlistPanel.test.tsx | 46 ++ .../AssistantChat/ScratchlistPanel.tsx | 29 +- web/src/components/SessionChat.tsx | 11 +- .../hooks/mutations/useSendMessage.test.tsx | 12 +- web/src/hooks/mutations/useSendMessage.ts | 8 +- web/src/hooks/useComposerDraft.test.ts | 149 ++++++- web/src/hooks/useComposerDraft.ts | 99 ++++- web/src/lib/suppressed-send-error.test.ts | 28 ++ web/src/lib/suppressed-send-error.ts | 21 + web/src/router.tsx | 29 +- 12 files changed, 983 insertions(+), 76 deletions(-) create mode 100644 web/src/components/AssistantChat/HappyComposer.sendError.test.tsx create mode 100644 web/src/lib/suppressed-send-error.test.ts create mode 100644 web/src/lib/suppressed-send-error.ts diff --git a/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx b/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx new file mode 100644 index 00000000..b5fb2b35 --- /dev/null +++ b/web/src/components/AssistantChat/HappyComposer.sendError.test.tsx @@ -0,0 +1,413 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode, TextareaHTMLAttributes } from 'react' +import { useRef, useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker' +import { HappyComposer, type ComposerSendError } from './HappyComposer' + +/** + * HappyComposer owns the recovery guard, while assistant-ui owns the live + * composer store. This focused harness supplies the small subset of that + * store necessary to exercise send → user interaction → delayed error races. + */ +type FakeAttachment = { id: string; status: { type: 'complete' } } +type MockComposerInputProps = TextareaHTMLAttributes & { + maxRows?: number + submitOnEnter?: boolean + cancelOnEscape?: boolean +} +type FakeRuntimeState = { + composer: { text: string; attachments: FakeAttachment[] } + thread: { isRunning: boolean; isDisabled: boolean } +} + +const runtime = vi.hoisted(() => ({ + snapshot: { + composer: { text: '', attachments: [] as FakeAttachment[] }, + thread: { isRunning: false, isDisabled: false }, + } as FakeRuntimeState, + setSnapshot: null as null | ((updater: (current: FakeRuntimeState) => FakeRuntimeState) => void), +})) + +vi.mock('@assistant-ui/react', async () => { + const React = await import('react') + return { + useAui: () => ({ + composer: () => ({ + setText: (text: string) => { + runtime.setSnapshot!((current) => ({ + ...current, + composer: { ...current.composer, text }, + })) + }, + send: () => { + runtime.setSnapshot!((current) => ({ + ...current, + composer: { text: '', attachments: [] }, + })) + }, + addAttachment: async () => {}, + }), + thread: () => ({ cancelRun: () => {} }), + }), + useAuiState: (selector: (state: typeof runtime.snapshot) => unknown) => selector(runtime.snapshot), + ComposerPrimitive: { + Root: ({ children, onSubmit }: { children: ReactNode; onSubmit?: () => void }) => ( +
{children}
+ ), + Input: React.forwardRef( + ({ onChange, maxRows: _maxRows, submitOnEnter: _submitOnEnter, cancelOnEscape: _cancelOnEscape, ...props }, ref) => ( +