From e403ca8e9716e7c667ac799ce51701e6a7c8e51a Mon Sep 17 00:00:00 2001 From: Kandev Agent Date: Sat, 23 May 2026 16:26:08 +0100 Subject: [PATCH] fix(cli-mode): preserve composer text on send failure + clarify dispatchOnly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two pieces of PR #1042 review feedback. 1. PassthroughComposer used to clear the textarea unconditionally after onSubmit resolved, but the WS handler swallowed transport errors and resolved normally — so a network blip silently wiped the user's typed message with no way to retry. Add a passthrough-specific send handler that rethrows after toasting, and gate the setValue("") in the composer on success. Preserves the ACP handler's swallow-and-resolve contract to avoid leaking unhandled rejections into the TaskChatPanel chain. New regression test: "preserves the typed text when onSubmit rejects". 2. Add a one-line comment at the passthrough branch in Executor.Prompt explaining why dispatchOnly is not forwarded — PTY writes are inherently fire-and-forget, so the flag has no analogue. Co-Authored-By: Claude Opus 4.7 --- .../executor/executor_interaction.go | 2 ++ .../task/preview-session-tabs.test.tsx | 16 +++++++++ .../components/task/preview-session-tabs.tsx | 36 +++++++++++++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index 0ab5130dc0..20eff1166d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -158,6 +158,8 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt // so the CLI agent actually receives it. The Preview-screen chat input (and // any other surface that calls message.add against a passthrough session) // reaches this branch via Service.PromptTask → Executor.Prompt. + // dispatchOnly is intentionally not forwarded: PTY writes are inherently + // fire-and-forget, so the flag has no analogue in passthrough mode. if e.agentManager.IsPassthroughSession(ctx, sessionID) { return e.promptPassthrough(ctx, taskID, sessionID, prompt, attachments) } diff --git a/apps/web/components/task/preview-session-tabs.test.tsx b/apps/web/components/task/preview-session-tabs.test.tsx index eb99e7f336..f361e96ab6 100644 --- a/apps/web/components/task/preview-session-tabs.test.tsx +++ b/apps/web/components/task/preview-session-tabs.test.tsx @@ -62,6 +62,22 @@ describe("PassthroughComposer", () => { expect(submitBtn.disabled).toBe(true); }); + it("preserves the typed text when onSubmit rejects so the user can retry", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("network down")); + render(); + + const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "important request" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + // Composer must NOT clear on failure — losing typed text on a flaky WS + // disconnect was a real UX regression (issue #989 review feedback). + expect(textarea.value).toBe("important request"); + // Composer must re-enable so the user can resubmit. + await waitFor(() => expect(textarea.disabled).toBe(false)); + }); + it("disables the composer while a submission is in flight", async () => { let resolveSubmit!: () => void; const submitPromise = new Promise((resolve) => { diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index 06eb00f3ed..be6a4f0ca9 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -144,6 +144,10 @@ function SessionAgentLogo({ profile }: { profile: AgentProfileOption | null | un function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) { const { toast } = useToast(); + // Used by the non-passthrough TaskChatPanel branch. Swallows errors after + // surfacing a toast because the existing chat-input-state.handleSubmit + // already optimistically clears the input before this resolves; rethrowing + // here would surface as an unhandled rejection further up the chain. const handleSendMessage = useCallback( async (content: string) => { const client = getWebSocketClient(); @@ -162,13 +166,37 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: [taskId, session.id, toast], ); + // Used by PassthroughComposer. Rethrows after toasting so the composer can + // keep the user's typed text intact on failure (Composer.submit's catch + // skips the setValue("") clear when onSubmit rejects). Separate from the + // ACP handler above to avoid leaking unhandled rejections into the chat- + // input-state chain that TaskChatPanel uses. + const handleSendPassthroughMessage = useCallback( + async (content: string) => { + const client = getWebSocketClient(); + if (!client) throw new Error("WebSocket client not available"); + try { + await client.request( + "message.add", + { task_id: taskId, session_id: session.id, content }, + 10000, + ); + } catch (error) { + console.error("Failed to send passthrough message:", error); + toast({ title: "Failed to send message", variant: "error" }); + throw error; + } + }, + [taskId, session.id, toast], + ); + if (session.is_passthrough) { return (
- +
); } @@ -202,7 +230,11 @@ export function PassthroughComposer({ setIsSending(true); try { await onSubmit(trimmed); - setValue(""); + setValue(""); // only clear on success — preserve text so user can retry on send failure + } catch { + // onSubmit (PreviewSessionBody.handleSendPassthroughMessage) already + // surfaced the error via toast; the user's typed value stays in the + // textarea intentionally so they can retry without retyping. } finally { setIsSending(false); }