From 8991b5d06fb8932e1cf6401055e02e1a17ccc5b3 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Thu, 18 Jun 2026 04:21:21 +0000 Subject: [PATCH] fix(mobile-chat): restore composer draft on genuine send error (mc#2908 F7) MobileChat cleared draft and pending files before awaiting sendMessage, so a genuine send error left the composer empty with no way to retry. Snapshot the composer state before clearing and restore it when sendError surfaces. Adds a regression test verifying the textarea is repopulated after the "agent may be unreachable" banner appears. Relates-to: #2908 --- canvas/src/components/mobile/MobileChat.tsx | 17 +++++++++++++++ .../mobile/__tests__/MobileChat.test.tsx | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index f4170b86..9aa934b0 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -219,6 +219,9 @@ export function MobileChat({ const composerRef = useRef(null); const fileInputRef = useRef(null); const [pendingFiles, setPendingFiles] = useState([]); + // Preserve composer state so a genuine send error can restore the draft + // and attachments instead of silently dropping them (mc#2908 F7). + const lastComposerRef = useRef<{ text: string; files: File[] } | null>(null); const { messages, @@ -254,6 +257,17 @@ export function MobileChat({ }, }); + // Restore draft + attachments when a genuine send error surfaces, so the + // user can retry without retyping (mc#2908 F7). Clear the snapshot after + // restoring to avoid replaying on unrelated error changes. + useEffect(() => { + if (sendError && lastComposerRef.current) { + setDraft(lastComposerRef.current.text); + setPendingFiles(lastComposerRef.current.files); + lastComposerRef.current = null; + } + }, [sendError, setDraft, setPendingFiles]); + // The agent is "thinking" when the user's send is in flight OR the workspace // reports an in-flight task — either way it's reachable, so clear any stale // "unreachable" banner the moment it's working (core#2745). Mirrors ChatTab. @@ -364,6 +378,9 @@ export function MobileChat({ // shipped this; mobile reused the hook but still blocked here.) if ((!text && pendingFiles.length === 0) || !reachable) return; clearError(); + // Snapshot the composer so a subsequent genuine send error can restore + // the draft and attachments instead of leaving the input empty (mc#2908 F7). + lastComposerRef.current = { text, files: [...pendingFiles] }; setDraft(""); const files = pendingFiles; setPendingFiles([]); diff --git a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx index 7349a971..22465973 100644 --- a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx +++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx @@ -762,6 +762,27 @@ describe("MobileChat — multi-send tap path (CR2 #2762)", () => { expect(container.textContent ?? "").not.toContain("unreachable"); }); }); + + it("restores composer draft after a genuine send error (mc#2908 F7)", async () => { + vi.spyOn(api, "post").mockRejectedValueOnce(new Error("boom")); + + const { container } = renderChat(mockAgentId); + const ta = container.querySelector("textarea") as HTMLTextAreaElement; + const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement; + + await act(async () => { + fireEvent.change(ta, { target: { value: "retry me" } }); + }); + await act(async () => { + sendBtn.click(); + }); + await waitFor(() => { + expect(container.textContent ?? "").toContain("unreachable"); + }); + + // Composer should be restored so the user can retry without retyping. + expect(ta.value).toBe("retry me"); + }); }); describe("MobileChat — tool-call chain (#231 desktop parity)", () => { -- 2.52.0