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);
}