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
|
// so the CLI agent actually receives it. The Preview-screen chat input (and
|
||||||
// any other surface that calls message.add against a passthrough session)
|
// any other surface that calls message.add against a passthrough session)
|
||||||
// reaches this branch via Service.PromptTask → Executor.Prompt.
|
// 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) {
|
if e.agentManager.IsPassthroughSession(ctx, sessionID) {
|
||||||
return e.promptPassthrough(ctx, taskID, sessionID, prompt, attachments)
|
return e.promptPassthrough(ctx, taskID, sessionID, prompt, attachments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ describe("PassthroughComposer", () => {
|
|||||||
expect(submitBtn.disabled).toBe(true);
|
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 () => {
|
it("disables the composer while a submission is in flight", async () => {
|
||||||
let resolveSubmit!: () => void;
|
let resolveSubmit!: () => void;
|
||||||
const submitPromise = new Promise<void>((resolve) => {
|
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 }) {
|
function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) {
|
||||||
const { toast } = useToast();
|
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(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
const client = getWebSocketClient();
|
const client = getWebSocketClient();
|
||||||
@@ -162,13 +166,37 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId:
|
|||||||
[taskId, session.id, toast],
|
[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) {
|
if (session.is_passthrough) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-card">
|
<div className="flex h-full flex-col bg-card">
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<PassthroughTerminal sessionId={session.id} mode="agent" />
|
<PassthroughTerminal sessionId={session.id} mode="agent" />
|
||||||
</div>
|
</div>
|
||||||
<PassthroughComposer onSubmit={handleSendMessage} />
|
<PassthroughComposer onSubmit={handleSendPassthroughMessage} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,7 +230,11 @@ export function PassthroughComposer({
|
|||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
await onSubmit(trimmed);
|
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 {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user