fix(webui): prevent composer draft rollback on refresh

This commit is contained in:
starship-s
2026-05-19 13:25:16 -06:00
parent 0310fcc466
commit 37df7d76a4
4 changed files with 46 additions and 6 deletions
+5 -1
View File
@@ -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":
+13 -4
View File
@@ -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);
@@ -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"
@@ -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