From 5f8b7e1adc718ad2b31540e4438aa0427ec086b0 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 31 May 2026 11:48:27 -0700 Subject: [PATCH] Fix Codex web composer submit timing --- crates/daemon/assets/index.html | 25 +++++++++++++++++-- crates/e2e/tests/web_smoke.rs | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index dba5ca9f..4bae1e70 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -1838,6 +1838,7 @@ }; const LARGE_TEXT_PASTE_CHARS = 16 * 1024; +const CODEX_COMPOSER_SUBMIT_DELAY_MS = 150; const WEB_PTY_ENGAGEMENT_MS = 3000; const WEB_PTY_RESIZE_DEBOUNCE_MS = 120; const COMPOSER_RESIZE_SUPPRESS_PTY_RESIZE_MS = 350; @@ -5244,6 +5245,10 @@ return result.reference || `[#file:${result.path}]`; } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function insertComposerText(text) { const start = inputEl.selectionStart ?? inputEl.value.length; const end = inputEl.selectionEnd ?? start; @@ -5267,9 +5272,17 @@ return btoa(binary); } +function currentSession() { + return state.sessions.find((x) => x.id === state.currentId) || null; +} + function currentSessionHasPty() { - const s = state.sessions.find((x) => x.id === state.currentId); - return isTerminalSession(s); + return isTerminalSession(currentSession()); +} + +function currentSessionHarness() { + const s = currentSession(); + return String((s && s.harness) || "").toLowerCase(); } function restoreComposerText(text, sessionId) { @@ -5294,10 +5307,18 @@ inputEl.value = ""; resetInputHeightPreservingTerminalScroll(); const pty = currentSessionHasPty(); + const harness = currentSessionHarness(); if (pty) { try { if (text) { await rpc("session.pty_input", { session_id: sid, data: encodeBase64Utf8(text) }); + if (enter && harness === "codex") { + // Codex classifies rapid terminal input as paste-like and suppresses + // Enter for a short window so pasted newlines stay in the draft. The + // web composer sends the whole prompt as one PTY write, so give Codex + // time to flush that burst before sending the submit Enter. + await sleep(CODEX_COMPOSER_SUBMIT_DELAY_MS); + } } if (enter) { await rpc("session.pty_input", { session_id: sid, data: encodeBase64Utf8("\r") }); diff --git a/crates/e2e/tests/web_smoke.rs b/crates/e2e/tests/web_smoke.rs index d946dfed..d62af82f 100644 --- a/crates/e2e/tests/web_smoke.rs +++ b/crates/e2e/tests/web_smoke.rs @@ -1110,6 +1110,49 @@ async fn web_client_loads_and_websocket_connects() { "Insert should still send PTY input: {fit_scroll:?}" ); + let codex_submit_delay: serde_json::Value = page + .evaluate( + r#" + (async () => { + const oldCurrent = state.currentId; + const oldSessions = state.sessions; + const oldRpc = rpc; + const oldSleep = sleep; + const input = document.getElementById('input'); + const calls = []; + rpc = async (method, params) => { + calls.push({ method, data: atob(params.data) }); + return null; + }; + sleep = async (ms) => { + calls.push({ sleep: ms }); + }; + state.currentId = 's-codex'; + state.sessions = [{ id: 's-codex', harness: 'codex', has_pty: true, mode: 'interactive' }]; + input.value = 'hello codex'; + await submitComposer({ enter: true }); + rpc = oldRpc; + sleep = oldSleep; + state.currentId = oldCurrent; + state.sessions = oldSessions; + return calls; + })() + "#, + ) + .await + .expect("evaluate codex submit delay") + .into_value::() + .expect("json array"); + assert_eq!( + codex_submit_delay, + serde_json::json!([ + { "method": "session.pty_input", "data": "hello codex" }, + { "sleep": 150 }, + { "method": "session.pty_input", "data": "\r" }, + ]), + "Codex composer submit should let Codex flush paste-burst detection before Enter: {codex_submit_delay:?}" + ); + // Reconnect regression: mobile keyboard show/hide can churn the browser's // viewport and, on some devices, the websocket. Reconnect must not hydrate // the selected terminal session through transcript/PTY replay, because that