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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user