fix(cli-mode): preserve composer text on send failure + clarify dispatchOnly

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 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-05-23 16:26:08 +01:00
parent b2eb2b4007
commit e403ca8e97
3 changed files with 52 additions and 2 deletions
@@ -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)
}
@@ -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(<PassthroughComposer onSubmit={onSubmit} />);
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<void>((resolve) => {
@@ -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 (
<div className="flex h-full flex-col bg-card">
<div className="flex-1 min-h-0">
<PassthroughTerminal sessionId={session.id} mode="agent" />
</div>
<PassthroughComposer onSubmit={handleSendMessage} />
<PassthroughComposer onSubmit={handleSendPassthroughMessage} />
</div>
);
}
@@ -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);
}