From 37df7d76a40c5c878b9486ee75bd3cfe45939703 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Tue, 19 May 2026 13:25:16 -0600 Subject: [PATCH] fix(webui): prevent composer draft rollback on refresh --- api/routes.py | 6 +++++- static/sessions.js | 17 +++++++++++++---- .../test_stage326_composer_draft_validation.py | 16 +++++++++++++++- tests/test_webui_external_refresh_frontend.py | 13 +++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/api/routes.py b/api/routes.py index 01671c2a..6e738d06 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4852,7 +4852,11 @@ def handle_post(handler, parsed) -> bool: if files is not None: draft["files"] = files s.composer_draft = draft - s.save() + # Draft persistence is not conversation activity. Touching updated_at + # here makes the active-session external-refresh poll force-reload the + # current chat every few seconds while the user is typing, and that + # delayed reload can restore an older draft over newer local input. + s.save(touch_updated_at=False) return j(handler, {"ok": True, "draft": s.composer_draft}) if parsed.path == "/api/session/update": diff --git a/static/sessions.js b/static/sessions.js index b3ffd7ed..fc9ae52b 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -47,7 +47,7 @@ function _saveComposerDraftNow(sid, text, files) { // Restore composer draft from server onto #msg textarea. // Only restores if there's actual text (skip empty/None drafts). // Guards against double-restore when rapidly switching sessions. -function _restoreComposerDraft(draft, targetSid) { +function _restoreComposerDraft(draft, targetSid, opts={}) { const ta = $('msg'); if (!ta) return; // targetSid is the session that was requested — if it no longer matches @@ -55,10 +55,20 @@ function _restoreComposerDraft(draft, targetSid) { if (targetSid && _loadingSessionId !== null && _loadingSessionId !== targetSid) return; const text = (draft && typeof draft.text === 'string') ? draft.text : ''; const files = (draft && Array.isArray(draft.files)) ? draft.files : []; + const current = ta.value || ''; + const preserveActiveInput = !!(opts && opts.preserveActiveInput); + + // Same-session force refreshes are driven by external state changes and may + // finish seconds after the user continued typing. In that case the local + // composer is the authoritative in-progress draft; never replace non-empty + // local input with an older server draft. Cross-session switches still restore + // normally so the previous session's composer contents do not leak forward. + if (preserveActiveInput && current && current !== text) return; + // If there's no text and no files, clear the textarea (a previous session's // draft may still be sitting there from a cross-session switch). if (!text && !files.length) { - if (ta.value) { + if (current) { ta.value = ''; if (typeof autoResize === 'function') autoResize(); if (typeof updateSendBtn === 'function') updateSendBtn(); @@ -66,7 +76,6 @@ function _restoreComposerDraft(draft, targetSid) { return; } // Only update if different to avoid cursor jumps on unrelated session switches. - const current = ta.value || ''; if (current !== text) { ta.value = text; if (typeof autoResize === 'function') autoResize(); @@ -790,7 +799,7 @@ async function loadSession(sid){ // against stale writes from slow responses racing to restore the previous draft). const _draft = S.session && S.session.composer_draft; if (_draft && (typeof _restoreComposerDraft === 'function')) { - _restoreComposerDraft(_draft, sid); + _restoreComposerDraft(_draft, sid, {preserveActiveInput:currentSid===sid&&forceReload}); } _resolveSessionModelForDisplaySoon(sid); diff --git a/tests/test_stage326_composer_draft_validation.py b/tests/test_stage326_composer_draft_validation.py index 71e3ecec..3f5904d6 100644 --- a/tests/test_stage326_composer_draft_validation.py +++ b/tests/test_stage326_composer_draft_validation.py @@ -81,10 +81,24 @@ def test_draft_validation_appears_before_persist(): src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8") # Anchor on the unique POST-validation comment marker. marker_idx = src.find("Stage-326 hardening (per Opus advisor)") - persist_idx = src.find("s.composer_draft = draft\n s.save()") + persist_idx = src.find("s.composer_draft = draft\n # Draft persistence is not conversation activity") assert marker_idx != -1 and persist_idx != -1, ( "could not locate validation marker or persist site" ) assert marker_idx < persist_idx, ( "validation block must run before composer_draft persist" ) + + +def test_draft_save_does_not_touch_session_updated_at(): + """Autosaving the composer must not look like conversation activity. + + If POST /api/session/draft bumps updated_at, the frontend's active-session + external refresh poll treats every keystroke autosave as a remote session + update and force-reloads the current chat a few seconds later. + """ + src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8") + persist_idx = src.find("s.composer_draft = draft") + assert persist_idx != -1, "could not locate composer draft persist site" + save_idx = src.find("s.save(touch_updated_at=False)", persist_idx) + assert save_idx != -1, "composer draft save must preserve session updated_at" diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index faf1fe1a..44b22f68 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -37,3 +37,16 @@ def test_force_reload_clears_stale_blocking_prompts_immediately(): """ assert "hideApprovalCard(forceReload)" in SESSIONS_JS assert "hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed')" in SESSIONS_JS + + +def test_same_session_force_reload_preserves_non_empty_composer_input(): + """A slow same-session refresh must not roll back text typed meanwhile. + + The active-session refresh path can finish seconds after it started. If the + user kept typing, restoring the server draft at the end of that load would + replace newer local input with an older debounced draft. + """ + assert "function _restoreComposerDraft(draft, targetSid, opts={})" in SESSIONS_JS + assert "const preserveActiveInput = !!(opts && opts.preserveActiveInput);" in SESSIONS_JS + assert "if (preserveActiveInput && current && current !== text) return;" in SESSIONS_JS + assert "_restoreComposerDraft(_draft, sid, {preserveActiveInput:currentSid===sid&&forceReload});" in SESSIONS_JS